From ebf1047da79805e86ec7a7e1058a2c50c55e42b0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:51:56 -0500 Subject: [PATCH 01/31] [core] Move build_info_data.h out of application.h to fix incremental rebuilds (#14230) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/version/version_text_sensor.cpp | 1 + esphome/components/web_server/web_server.cpp | 2 +- esphome/core/application.cpp | 11 +++++++++++ esphome/core/application.h | 18 ++++++++---------- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 2e5686008b..74bb4c76e8 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -1,5 +1,6 @@ #include "version_text_sensor.h" #include "esphome/core/application.h" +#include "esphome/core/build_info_data.h" #include "esphome/core/log.h" #include "esphome/core/version.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 4b572417c1..682008c40e 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -375,7 +375,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { JsonObject root = builder.root(); root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); - char comment_buffer[ESPHOME_COMMENT_SIZE]; + char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); root[ESPHOME_F("comment")] = comment_buffer; #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index c6597897dc..1cb7dc0075 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -749,4 +749,15 @@ void Application::get_build_time_string(std::span buf buffer[buffer.size() - 1] = '\0'; } +void Application::get_comment_string(std::span buffer) { + ESPHOME_strncpy_P(buffer.data(), ESPHOME_COMMENT_STR, ESPHOME_COMMENT_SIZE); + buffer[ESPHOME_COMMENT_SIZE - 1] = '\0'; +} + +uint32_t Application::get_config_hash() { return ESPHOME_CONFIG_HASH; } + +uint32_t Application::get_config_version_hash() { return fnv1a_hash_extend(ESPHOME_CONFIG_HASH, ESPHOME_VERSION); } + +time_t Application::get_build_time() { return ESPHOME_BUILD_TIME; } + } // namespace esphome diff --git a/esphome/core/application.h b/esphome/core/application.h index 5b3e3dfed6..cd275bb97f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -6,7 +6,6 @@ #include #include #include -#include "esphome/core/build_info_data.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -274,16 +273,15 @@ class Application { return ""; } + /// Maximum size of the comment buffer (including null terminator) + static constexpr size_t ESPHOME_COMMENT_SIZE_MAX = 256; + /// Copy the comment string into the provided buffer - /// Buffer must be ESPHOME_COMMENT_SIZE bytes (compile-time enforced) - void get_comment_string(std::span buffer) { - ESPHOME_strncpy_P(buffer.data(), ESPHOME_COMMENT_STR, buffer.size()); - buffer[buffer.size() - 1] = '\0'; - } + void get_comment_string(std::span buffer); /// Get the comment of this Application as a string std::string get_comment() { - char buffer[ESPHOME_COMMENT_SIZE]; + char buffer[ESPHOME_COMMENT_SIZE_MAX]; this->get_comment_string(buffer); return std::string(buffer); } @@ -294,13 +292,13 @@ class Application { static constexpr size_t BUILD_TIME_STR_SIZE = 26; /// Get the config hash as a 32-bit integer - constexpr uint32_t get_config_hash() { return ESPHOME_CONFIG_HASH; } + uint32_t get_config_hash(); /// Get the config hash extended with ESPHome version - constexpr uint32_t get_config_version_hash() { return fnv1a_hash_extend(ESPHOME_CONFIG_HASH, ESPHOME_VERSION); } + uint32_t get_config_version_hash(); /// Get the build time as a Unix timestamp - constexpr time_t get_build_time() { return ESPHOME_BUILD_TIME; } + time_t get_build_time(); /// Copy the build time string into the provided buffer /// Buffer must be BUILD_TIME_STR_SIZE bytes (compile-time enforced) From 30cc51eac97948449aa87a4e741bab84bb79d6a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 19:49:00 -0600 Subject: [PATCH 02/31] [version] Use C++17 nested namespace syntax (#14240) --- esphome/components/version/version_text_sensor.cpp | 6 ++---- esphome/components/version/version_text_sensor.h | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 74bb4c76e8..4a08001cc4 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -6,8 +6,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/progmem.h" -namespace esphome { -namespace version { +namespace esphome::version { static const char *const TAG = "version.text_sensor"; @@ -36,5 +35,4 @@ void VersionTextSensor::setup() { void VersionTextSensor::set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void VersionTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Version Text Sensor", this); } -} // namespace version -} // namespace esphome +} // namespace esphome::version diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index b7d8001120..6153c5dd7c 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace version { +namespace esphome::version { class VersionTextSensor : public text_sensor::TextSensor, public Component { public: @@ -16,5 +15,4 @@ class VersionTextSensor : public text_sensor::TextSensor, public Component { bool hide_timestamp_{false}; }; -} // namespace version -} // namespace esphome +} // namespace esphome::version From 843d06df3f5bf63e9447d1a10eaaa6987f0b5705 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 19:49:15 -0600 Subject: [PATCH 03/31] [switch] Use C++17 nested namespace syntax (#14241) --- esphome/components/switch/automation.cpp | 6 ++---- esphome/components/switch/automation.h | 6 ++---- .../switch/binary_sensor/switch_binary_sensor.cpp | 6 ++---- .../switch/binary_sensor/switch_binary_sensor.h | 6 ++---- esphome/components/switch/switch.cpp | 6 ++---- esphome/components/switch/switch.h | 14 ++++++-------- 6 files changed, 16 insertions(+), 28 deletions(-) diff --git a/esphome/components/switch/automation.cpp b/esphome/components/switch/automation.cpp index 5989ae9ce3..9a0221fe56 100644 --- a/esphome/components/switch/automation.cpp +++ b/esphome/components/switch/automation.cpp @@ -1,10 +1,8 @@ #include "automation.h" #include "esphome/core/log.h" -namespace esphome { -namespace switch_ { +namespace esphome::switch_ { static const char *const TAG = "switch.automation"; -} // namespace switch_ -} // namespace esphome +} // namespace esphome::switch_ diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index 27d3474c97..ed1f056c8b 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace switch_ { +namespace esphome::switch_ { template class TurnOnAction : public Action { public: @@ -104,5 +103,4 @@ template class SwitchPublishAction : public Action { Switch *switch_; }; -} // namespace switch_ -} // namespace esphome +} // namespace esphome::switch_ diff --git a/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp b/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp index ba57154446..19995fb1ae 100644 --- a/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp +++ b/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp @@ -1,8 +1,7 @@ #include "switch_binary_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace switch_ { +namespace esphome::switch_ { static const char *const TAG = "switch.binary_sensor"; @@ -13,5 +12,4 @@ void SwitchBinarySensor::setup() { void SwitchBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Switch Binary Sensor", this); } -} // namespace switch_ -} // namespace esphome +} // namespace esphome::switch_ diff --git a/esphome/components/switch/binary_sensor/switch_binary_sensor.h b/esphome/components/switch/binary_sensor/switch_binary_sensor.h index 53b07da903..0b77cdd920 100644 --- a/esphome/components/switch/binary_sensor/switch_binary_sensor.h +++ b/esphome/components/switch/binary_sensor/switch_binary_sensor.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace switch_ { +namespace esphome::switch_ { class SwitchBinarySensor : public binary_sensor::BinarySensor, public Component { public: @@ -17,5 +16,4 @@ class SwitchBinarySensor : public binary_sensor::BinarySensor, public Component Switch *source_; }; -} // namespace switch_ -} // namespace esphome +} // namespace esphome::switch_ diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 61a273d25c..9e9af21368 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -3,8 +3,7 @@ #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" -namespace esphome { -namespace switch_ { +namespace esphome::switch_ { static const char *const TAG = "switch"; @@ -107,5 +106,4 @@ void log_switch(const char *tag, const char *prefix, const char *type, Switch *o } } -} // namespace switch_ -} // namespace esphome +} // namespace esphome::switch_ diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index 9319adf9ed..982c640cf9 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -5,8 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" -namespace esphome { -namespace switch_ { +namespace esphome::switch_ { #define SUB_SWITCH(name) \ protected: \ @@ -16,10 +15,10 @@ namespace switch_ { void set_##name##_switch(switch_::Switch *s) { this->name##_switch_ = s; } // bit0: on/off. bit1: persistent. bit2: inverted. bit3: disabled -const int RESTORE_MODE_ON_MASK = 0x01; -const int RESTORE_MODE_PERSISTENT_MASK = 0x02; -const int RESTORE_MODE_INVERTED_MASK = 0x04; -const int RESTORE_MODE_DISABLED_MASK = 0x08; +constexpr int RESTORE_MODE_ON_MASK = 0x01; +constexpr int RESTORE_MODE_PERSISTENT_MASK = 0x02; +constexpr int RESTORE_MODE_INVERTED_MASK = 0x04; +constexpr int RESTORE_MODE_DISABLED_MASK = 0x08; enum SwitchRestoreMode : uint8_t { SWITCH_ALWAYS_OFF = !RESTORE_MODE_ON_MASK, @@ -146,5 +145,4 @@ class Switch : public EntityBase, public EntityBase_DeviceClass { #define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj)) void log_switch(const char *tag, const char *prefix, const char *type, Switch *obj); -} // namespace switch_ -} // namespace esphome +} // namespace esphome::switch_ From 63c1496115befafbdf8565f48b34ab7a6ea57753 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 19:49:25 -0600 Subject: [PATCH 04/31] [text] Use C++17 nested namespace syntax (#14242) --- esphome/components/text/automation.h | 6 ++---- esphome/components/text/text.cpp | 6 ++---- esphome/components/text/text.h | 6 ++---- esphome/components/text/text_call.cpp | 6 ++---- esphome/components/text/text_call.h | 6 ++---- esphome/components/text/text_traits.h | 6 ++---- 6 files changed, 12 insertions(+), 24 deletions(-) diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h index e7667fe491..ac8166d0be 100644 --- a/esphome/components/text/automation.h +++ b/esphome/components/text/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "text.h" -namespace esphome { -namespace text { +namespace esphome::text { class TextStateTrigger : public Trigger { public: @@ -29,5 +28,4 @@ template class TextSetAction : public Action { Text *text_; }; -} // namespace text -} // namespace esphome +} // namespace esphome::text diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp index e3f74b685b..d8ab6b1b92 100644 --- a/esphome/components/text/text.cpp +++ b/esphome/components/text/text.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace text { +namespace esphome::text { static const char *const TAG = "text"; @@ -34,5 +33,4 @@ void Text::add_on_state_callback(std::function &&call this->state_callback_.add(std::move(callback)); } -} // namespace text -} // namespace esphome +} // namespace esphome::text diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h index 3a1bea56cb..7d255e5688 100644 --- a/esphome/components/text/text.h +++ b/esphome/components/text/text.h @@ -6,8 +6,7 @@ #include "text_call.h" #include "text_traits.h" -namespace esphome { -namespace text { +namespace esphome::text { #define LOG_TEXT(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -47,5 +46,4 @@ class Text : public EntityBase { LazyCallbackManager state_callback_; }; -} // namespace text -} // namespace esphome +} // namespace esphome::text diff --git a/esphome/components/text/text_call.cpp b/esphome/components/text/text_call.cpp index 0d0a1d228d..8a1630c5ca 100644 --- a/esphome/components/text/text_call.cpp +++ b/esphome/components/text/text_call.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "text.h" -namespace esphome { -namespace text { +namespace esphome::text { static const char *const TAG = "text"; @@ -52,5 +51,4 @@ void TextCall::perform() { this->parent_->control(target_value); } -} // namespace text -} // namespace esphome +} // namespace esphome::text diff --git a/esphome/components/text/text_call.h b/esphome/components/text/text_call.h index 9f75a25c6b..532fae34b2 100644 --- a/esphome/components/text/text_call.h +++ b/esphome/components/text/text_call.h @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "text_traits.h" -namespace esphome { -namespace text { +namespace esphome::text { class Text; @@ -21,5 +20,4 @@ class TextCall { void validate_(); }; -} // namespace text -} // namespace esphome +} // namespace esphome::text diff --git a/esphome/components/text/text_traits.h b/esphome/components/text/text_traits.h index 473daafb8e..72e65b83ce 100644 --- a/esphome/components/text/text_traits.h +++ b/esphome/components/text/text_traits.h @@ -4,8 +4,7 @@ #include "esphome/core/string_ref.h" -namespace esphome { -namespace text { +namespace esphome::text { enum TextMode : uint8_t { TEXT_MODE_TEXT = 0, @@ -37,5 +36,4 @@ class TextTraits { TextMode mode_{TEXT_MODE_TEXT}; }; -} // namespace text -} // namespace esphome +} // namespace esphome::text From 500aa7bf1d27378ea94141babdad6c83b0176f4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 19:49:35 -0600 Subject: [PATCH 05/31] [text_sensor] Use C++17 nested namespace syntax (#14243) --- esphome/components/text_sensor/automation.h | 6 ++---- esphome/components/text_sensor/filter.cpp | 6 ++---- esphome/components/text_sensor/filter.h | 6 ++---- esphome/components/text_sensor/text_sensor.cpp | 6 ++---- esphome/components/text_sensor/text_sensor.h | 6 ++---- 5 files changed, 10 insertions(+), 20 deletions(-) diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h index 709c54c140..ab30362774 100644 --- a/esphome/components/text_sensor/automation.h +++ b/esphome/components/text_sensor/automation.h @@ -6,8 +6,7 @@ #include "esphome/core/automation.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace text_sensor { +namespace esphome::text_sensor { class TextSensorStateTrigger : public Trigger { public: @@ -46,5 +45,4 @@ template class TextSensorPublishAction : public Action { TextSensor *sensor_; }; -} // namespace text_sensor -} // namespace esphome +} // namespace esphome::text_sensor diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index f6552c7c66..f7c6a695fb 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -6,8 +6,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace text_sensor { +namespace esphome::text_sensor { static const char *const TAG = "text_sensor.filter"; @@ -107,7 +106,6 @@ bool MapFilter::new_value(std::string &value) { return true; // Pass through if no match } -} // namespace text_sensor -} // namespace esphome +} // namespace esphome::text_sensor #endif // USE_TEXT_SENSOR_FILTER diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index f88e8645cc..8a8bc55c8e 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -6,8 +6,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace text_sensor { +namespace esphome::text_sensor { class TextSensor; @@ -165,7 +164,6 @@ class MapFilter : public Filter { FixedVector mappings_; }; -} // namespace text_sensor -} // namespace esphome +} // namespace esphome::text_sensor #endif // USE_TEXT_SENSOR_FILTER diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index c66d08ec40..91561c5f42 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace text_sensor { +namespace esphome::text_sensor { static const char *const TAG = "text_sensor"; @@ -125,5 +124,4 @@ void TextSensor::notify_frontend_() { #endif } -} // namespace text_sensor -} // namespace esphome +} // namespace esphome::text_sensor diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 97373dc716..9916aa63b2 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -10,8 +10,7 @@ #include #include -namespace esphome { -namespace text_sensor { +namespace esphome::text_sensor { class TextSensor; @@ -84,5 +83,4 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { #endif }; -} // namespace text_sensor -} // namespace esphome +} // namespace esphome::text_sensor From a694003fe3f2aca98e930f9d1836d43ac2ee05d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 19:49:48 -0600 Subject: [PATCH 06/31] [usb_host] Use C++17 nested namespace syntax (#14244) --- esphome/components/usb_host/usb_host.h | 26 +++++++++---------- .../components/usb_host/usb_host_client.cpp | 6 ++--- .../usb_host/usb_host_component.cpp | 6 ++--- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index a6a97d0bd7..2eec0c9699 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -12,8 +12,7 @@ #include "esphome/core/event_pool.h" #include -namespace esphome { -namespace usb_host { +namespace esphome::usb_host { // THREADING MODEL: // This component uses a dedicated USB task for event processing to prevent data loss. @@ -44,16 +43,16 @@ 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; -static const uint8_t USB_RECIP_ENDPOINT = 2; -static const uint8_t USB_TYPE_STANDARD = 0 << 5; -static const uint8_t USB_TYPE_CLASS = 1 << 5; -static const uint8_t USB_TYPE_VENDOR = 2 << 5; -static const uint8_t USB_DIR_MASK = 1 << 7; -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 constexpr uint8_t USB_RECIP_DEVICE = 0; +static constexpr uint8_t USB_RECIP_INTERFACE = 1; +static constexpr uint8_t USB_RECIP_ENDPOINT = 2; +static constexpr uint8_t USB_TYPE_STANDARD = 0 << 5; +static constexpr uint8_t USB_TYPE_CLASS = 1 << 5; +static constexpr uint8_t USB_TYPE_VENDOR = 2 << 5; +static constexpr uint8_t USB_DIR_MASK = 1 << 7; +static constexpr uint8_t USB_DIR_IN = 1 << 7; +static constexpr uint8_t USB_DIR_OUT = 0; +static constexpr size_t SETUP_PACKET_SIZE = 8; static constexpr size_t MAX_REQUESTS = USB_HOST_MAX_REQUESTS; // maximum number of outstanding requests possible. static_assert(MAX_REQUESTS >= 1 && MAX_REQUESTS <= 32, "MAX_REQUESTS must be between 1 and 32"); @@ -189,7 +188,6 @@ class USBHost : public Component { std::vector clients_{}; }; -} // namespace usb_host -} // namespace esphome +} // namespace esphome::usb_host #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 422d74095c..5b0aed4c59 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -10,8 +10,7 @@ #include #include #include -namespace esphome { -namespace usb_host { +namespace esphome::usb_host { #pragma GCC diagnostic ignored "-Wparentheses" @@ -568,6 +567,5 @@ void USBClient::release_trq(TransferRequest *trq) { this->trq_in_use_.fetch_and(mask, std::memory_order_release); } -} // namespace usb_host -} // namespace esphome +} // namespace esphome::usb_host #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 790fe6713b..8ce0a70dc9 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -4,8 +4,7 @@ #include #include "esphome/core/log.h" -namespace esphome { -namespace usb_host { +namespace esphome::usb_host { void USBHost::setup() { usb_host_config_t config{}; @@ -28,7 +27,6 @@ void USBHost::loop() { } } -} // namespace usb_host -} // namespace esphome +} // namespace esphome::usb_host #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 From 1614eb9c9cc3be784d22f262171b7978b21ebbb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 19:50:00 -0600 Subject: [PATCH 07/31] [i2c] Use C++17 nested namespace syntax (#14245) --- esphome/components/i2c/i2c.cpp | 6 ++---- esphome/components/i2c/i2c.h | 6 ++---- esphome/components/i2c/i2c_bus.h | 6 ++---- esphome/components/i2c/i2c_bus_arduino.cpp | 6 ++---- esphome/components/i2c/i2c_bus_arduino.h | 6 ++---- esphome/components/i2c/i2c_bus_esp_idf.cpp | 6 ++---- esphome/components/i2c/i2c_bus_esp_idf.h | 6 ++---- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index b9b5d79428..76b3ec3b4d 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace i2c { +namespace esphome::i2c { static const char *const TAG = "i2c"; @@ -109,5 +108,4 @@ uint8_t I2CRegister16::get() const { return value; } -} // namespace i2c -} // namespace esphome +} // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index aab98d5f46..00929db620 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -6,8 +6,7 @@ #include "esphome/core/optional.h" #include "i2c_bus.h" -namespace esphome { -namespace i2c { +namespace esphome::i2c { #define LOG_I2C_DEVICE(this) ESP_LOGCONFIG(TAG, " Address: 0x%02X", this->address_); @@ -272,5 +271,4 @@ class I2CDevice { I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance }; -} // namespace i2c -} // namespace esphome +} // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c_bus.h b/esphome/components/i2c/i2c_bus.h index 2bc0dc1ef9..0c5e80bfe5 100644 --- a/esphome/components/i2c/i2c_bus.h +++ b/esphome/components/i2c/i2c_bus.h @@ -6,8 +6,7 @@ #include "esphome/core/helpers.h" -namespace esphome { -namespace i2c { +namespace esphome::i2c { /// @brief Error codes returned by I2CBus and I2CDevice methods enum ErrorCode { @@ -69,5 +68,4 @@ class InternalI2CBus : public I2CBus { virtual int get_port() const = 0; }; -} // namespace i2c -} // namespace esphome +} // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index edd6b81588..5120eb4c00 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -7,8 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace i2c { +namespace esphome::i2c { static const char *const TAG = "i2c.arduino"; @@ -262,7 +261,6 @@ void ArduinoI2CBus::recover_() { recovery_result_ = RECOVERY_COMPLETED; } -} // namespace i2c -} // namespace esphome +} // namespace esphome::i2c #endif // defined(USE_ARDUINO) && !defined(USE_ESP32) diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index 2d69e7684c..edc14af7bc 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -6,8 +6,7 @@ #include "esphome/core/component.h" #include "i2c_bus.h" -namespace esphome { -namespace i2c { +namespace esphome::i2c { enum RecoveryCode { RECOVERY_FAILED_SCL_LOW, @@ -45,7 +44,6 @@ class ArduinoI2CBus : public InternalI2CBus, public Component { bool initialized_ = false; }; -} // namespace i2c -} // namespace esphome +} // namespace esphome::i2c #endif // defined(USE_ARDUINO) && !defined(USE_ESP32) diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 7a965ce5ad..eaefabf75b 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -10,8 +10,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace i2c { +namespace esphome::i2c { static const char *const TAG = "i2c.idf"; @@ -312,6 +311,5 @@ void IDFI2CBus::recover_() { recovery_result_ = RECOVERY_COMPLETED; } -} // namespace i2c -} // namespace esphome +} // namespace esphome::i2c #endif // USE_ESP32 diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index 84f4616967..c23f9f0c54 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -6,8 +6,7 @@ #include "i2c_bus.h" #include -namespace esphome { -namespace i2c { +namespace esphome::i2c { enum RecoveryCode { RECOVERY_FAILED_SCL_LOW, @@ -56,7 +55,6 @@ class IDFI2CBus : public InternalI2CBus, public Component { #endif }; -} // namespace i2c -} // namespace esphome +} // namespace esphome::i2c #endif // USE_ESP32 From 70e47f301d0dd936ce0d30241aa1427fbab02812 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 19:50:11 -0600 Subject: [PATCH 08/31] [ethernet] Use C++17 nested namespace syntax (#14246) --- esphome/components/ethernet/ethernet_component.cpp | 6 ++---- esphome/components/ethernet/ethernet_component.h | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index fcd32223e4..f855bc89cc 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -19,8 +19,7 @@ #include #endif -namespace esphome { -namespace ethernet { +namespace esphome::ethernet { #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) // work around IDF compile issue on P4 https://github.com/espressif/esp-idf/pull/15637 @@ -881,7 +880,6 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi #endif -} // namespace ethernet -} // namespace esphome +} // namespace esphome::ethernet #endif // USE_ESP32 diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index b4859c308d..1cd44d2b2c 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -15,8 +15,7 @@ #include "esp_mac.h" #include "esp_idf_version.h" -namespace esphome { -namespace ethernet { +namespace esphome::ethernet { #ifdef USE_ETHERNET_IP_STATE_LISTENERS /** Listener interface for Ethernet IP state changes. @@ -218,7 +217,6 @@ extern EthernetComponent *global_eth_component; extern "C" esp_eth_phy_t *esp_eth_phy_new_jl1101(const eth_phy_config_t *config); #endif -} // namespace ethernet -} // namespace esphome +} // namespace esphome::ethernet #endif // USE_ESP32 From 7d9d90d3f8c4b431b74ad197f9f354f9a4795f59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 19:50:22 -0600 Subject: [PATCH 09/31] [cse7766] Use C++17 nested namespace syntax (#14247) --- esphome/components/cse7766/cse7766.cpp | 6 ++---- esphome/components/cse7766/cse7766.h | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 7ffdf757a0..806b79e19e 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace cse7766 { +namespace esphome::cse7766 { static const char *const TAG = "cse7766"; @@ -258,5 +257,4 @@ void CSE7766Component::dump_config() { this->check_uart_settings(4800, 1, uart::UART_CONFIG_PARITY_EVEN); } -} // namespace cse7766 -} // namespace esphome +} // namespace esphome::cse7766 diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index 66a4e04633..77b80dd824 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -5,8 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -namespace esphome { -namespace cse7766 { +namespace esphome::cse7766 { static constexpr size_t CSE7766_RAW_DATA_SIZE = 24; @@ -49,5 +48,4 @@ class CSE7766Component : public Component, public uart::UARTDevice { uint16_t cf_pulses_last_{0}; }; -} // namespace cse7766 -} // namespace esphome +} // namespace esphome::cse7766 From ad2da0af52469c18b8741a60e6b4aa977415bf8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 20:00:21 -0600 Subject: [PATCH 10/31] [network] Use C++17 nested namespace syntax (#14248) --- esphome/components/network/ip_address.h | 6 ++---- esphome/components/network/util.cpp | 6 ++---- esphome/components/network/util.h | 6 ++---- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index b2a2c563e2..c0e7b2886c 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -37,8 +37,7 @@ using ip4_addr_t = in_addr; #include #endif -namespace esphome { -namespace network { +namespace esphome::network { /// Buffer size for IP address string (IPv6 max: 39 chars + null) static constexpr size_t IP_ADDRESS_BUFFER_SIZE = 40; @@ -187,6 +186,5 @@ struct IPAddress { using IPAddresses = std::array; -} // namespace network -} // namespace esphome +} // namespace esphome::network #endif diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 5e741fd244..e397d77077 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -17,8 +17,7 @@ #include "esphome/components/modem/modem_component.h" #endif -namespace esphome { -namespace network { +namespace esphome::network { // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as // an AP that use a previous interface for NAT). @@ -109,6 +108,5 @@ const char *get_use_address() { #endif } -} // namespace network -} // namespace esphome +} // namespace esphome::network #endif diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 3dc12232aa..ae949ab0a8 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -4,8 +4,7 @@ #include #include "ip_address.h" -namespace esphome { -namespace network { +namespace esphome::network { /// Return whether the node is connected to the network (through wifi, eth, ...) bool is_connected(); @@ -15,6 +14,5 @@ bool is_disabled(); const char *get_use_address(); IPAddresses get_ip_addresses(); -} // namespace network -} // namespace esphome +} // namespace esphome::network #endif From 64364961dba73edebd267bb0b0021678578cc48b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 20:47:54 -0600 Subject: [PATCH 11/31] [core] Replace lwip_select() with direct rcvevent reads on ESP32 On ESP32, replace lwip_select() in yield_with_select_() with direct socket event reads via lwip_socket_dbg_get_socket(), reducing poll cost from 133us to ~7us (18.7x faster). Replace the UDP loopback wake socket with FreeRTOS task notifications (<2us, ISR-safe). Benchmarks (ESP32, 4 listen sockets): - Poll path: 7,087 ns vs 132,402 ns (18.7x faster) - Wake signal: 1,826 ns vs ~130,000 ns (72x faster, now ISR-safe) - Binary: yield_with_select_ 108B vs 188B (43% smaller) Non-ESP32 platforms (LibreTiny, ESP8266, RP2040) are unchanged. --- esphome/components/socket/__init__.py | 15 +++-- esphome/core/application.cpp | 89 ++++++++++++++++++++------- esphome/core/application.h | 27 ++++---- esphome/core/lwip_fast_select.c | 84 +++++++++++++++++++++++++ esphome/core/lwip_fast_select.h | 37 +++++++++++ 5 files changed, 211 insertions(+), 41 deletions(-) create mode 100644 esphome/core/lwip_fast_select.c create mode 100644 esphome/core/lwip_fast_select.h diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index d82f0c7aba..06b0ba2bf7 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -126,13 +126,14 @@ def require_wake_loop_threadsafe() -> None: """Mark that wake_loop_threadsafe support is required by a component. Call this from components that need to wake the main event loop from background threads. - This enables the shared UDP loopback socket mechanism (~208 bytes RAM). - The socket is shared across all components that use this feature. + + On ESP32: Uses FreeRTOS task notifications (<1 us, ISR-safe, no socket needed). + On other platforms: Uses a shared UDP loopback socket mechanism (~208 bytes RAM). This call is a no-op if networking is not enabled in the configuration. - IMPORTANT: This is for background thread context only, NOT ISR context. - Socket operations are not safe to call from ISR handlers. + On ESP32, this is safe to call from ISR context. + On other platforms, this is for background thread context only, NOT ISR context. Example: from esphome.components import socket @@ -147,8 +148,10 @@ def require_wake_loop_threadsafe() -> None: ): CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True cg.add_define("USE_WAKE_LOOP_THREADSAFE") - # Consume 1 socket for the shared wake notification socket - consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) + if not CORE.is_esp32: + # Only non-ESP32 platforms need a UDP socket for wake notifications. + # ESP32 uses FreeRTOS task notifications instead (no socket needed). + consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 1cb7dc0075..9de5aa1e9e 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -9,6 +9,9 @@ #endif #ifdef USE_ESP32 #include +#include "esphome/core/lwip_fast_select.h" +#include +#include #endif #include "esphome/core/version.h" #include "esphome/core/hal.h" @@ -145,8 +148,13 @@ void Application::setup() { #endif #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#ifdef USE_ESP32 + // Initialize fast select: saves main loop task handle for xTaskNotifyGive wake + esphome_lwip_fast_select_init(); +#else // Set up wake socket for waking main loop from tasks this->setup_wake_loop_threadsafe_(); +#endif #endif this->schedule_dump_config(); @@ -523,7 +531,7 @@ void Application::enable_pending_loops_() { } void Application::before_loop_tasks_(uint32_t loop_start_time) { -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif @@ -576,11 +584,15 @@ bool Application::register_socket_fd(int fd) { #endif this->socket_fds_.push_back(fd); +#ifdef USE_ESP32 + // Hook the socket's netconn callback for instant wake on receive events + esphome_lwip_hook_socket(fd); +#else this->socket_fds_changed_ = true; - if (fd > this->max_fd_) { this->max_fd_ = fd; } +#endif return true; } @@ -599,8 +611,11 @@ void Application::unregister_socket_fd(int fd) { if (i < this->socket_fds_.size() - 1) this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); +#ifdef USE_ESP32 + // Unhook the socket's netconn callback + esphome_lwip_unhook_socket(fd); +#else this->socket_fds_changed_ = true; - // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { this->max_fd_ = -1; @@ -609,6 +624,7 @@ void Application::unregister_socket_fd(int fd) { this->max_fd_ = sock_fd; } } +#endif return; } } @@ -616,16 +632,34 @@ void Application::unregister_socket_fd(int fd) { #endif void Application::yield_with_select_(uint32_t delay_ms) { - // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run - // since select() with 0 timeout only polls without yielding. -#ifdef USE_SOCKET_SELECT_SUPPORT - if (!this->socket_fds_.empty()) { + // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run. +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) + // ESP32 fast path: direct rcvevent reads (~858 ns for 4 sockets vs 133 us for lwip_select) + if (!this->socket_fds_.empty()) [[likely]] { + FD_ZERO(&this->read_fds_); + for (int fd : this->socket_fds_) { + if (esphome_lwip_socket_has_data(fd)) { + FD_SET(fd, &this->read_fds_); + } + } + } + + if (delay_ms == 0) [[unlikely]] { + yield(); + return; + } + + // Sleep with instant wake via FreeRTOS task notification. + // Woken by: callback wrapper (socket data arrives), wake_loop_threadsafe() (other tasks), or timeout. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms)); + +#elif defined(USE_SOCKET_SELECT_SUPPORT) + // Non-ESP32 platforms (LibreTiny bk72xx/rtl87xx): use select() + if (!this->socket_fds_.empty()) [[likely]] { // Update fd_set if socket list has changed - if (this->socket_fds_changed_) { + if (this->socket_fds_changed_) [[unlikely]] { FD_ZERO(&this->base_read_fds_); - // fd bounds are already validated in register_socket_fd() or guaranteed by platform design: - // - ESP32: LwIP guarantees fd < FD_SETSIZE by design (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS) - // - Other platforms: register_socket_fd() validates fd < FD_SETSIZE + // fd bounds are validated in register_socket_fd() for (int fd : this->socket_fds_) { FD_SET(fd, &this->base_read_fds_); } @@ -641,7 +675,7 @@ void Application::yield_with_select_(uint32_t delay_ms) { tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000; // Call select with timeout -#if defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || (defined(USE_ESP32) && defined(USE_SOCKET_IMPL_BSD_SOCKETS)) +#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS int ret = lwip_select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); #else int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); @@ -651,19 +685,18 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret < 0: error (except EINTR which is normal) // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected - if (ret < 0 && errno != EINTR) { - // Actual error - log and fall back to delay - ESP_LOGW(TAG, "select() failed with errno %d", errno); - delay(delay_ms); + if (ret >= 0 || errno == EINTR) [[likely]] { + // Yield if zero timeout since select(0) only polls without yielding + if (delay_ms == 0) [[unlikely]] { + yield(); + } + return; } - // When delay_ms is 0, we need to yield since select(0) doesn't yield - if (delay_ms == 0) { - yield(); - } - } else { - // No sockets registered, use regular delay - delay(delay_ms); + // select() error - log and fall through to delay() + ESP_LOGW(TAG, "select() failed with errno %d", errno); } + // No sockets registered or select() failed - use regular delay + delay(delay_ms); #elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) // No select support but can wake on socket activity via esp_schedule() socket::socket_delay(delay_ms); @@ -676,6 +709,14 @@ void Application::yield_with_select_(uint32_t delay_ms) { Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + +#ifdef USE_ESP32 +void Application::wake_loop_threadsafe() { + // Direct FreeRTOS task notification — ISR-safe, <1 us + esphome_lwip_wake_main_loop(); +} +#else // !USE_ESP32 + void Application::setup_wake_loop_threadsafe_() { // Create UDP socket for wake notifications this->wake_socket_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); @@ -742,6 +783,8 @@ void Application::wake_loop_threadsafe() { lwip_send(this->wake_socket_fd_, &dummy, 1, 0); } } +#endif // USE_ESP32 + #endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) void Application::get_build_time_string(std::span buffer) { diff --git a/esphome/core/application.h b/esphome/core/application.h index cd275bb97f..fba2ae54c2 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -25,7 +25,7 @@ #ifdef USE_SOCKET_SELECT_SUPPORT #include -#ifdef USE_WAKE_LOOP_THREADSAFE +#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) #include #endif #endif // USE_SOCKET_SELECT_SUPPORT @@ -497,9 +497,10 @@ class Application { bool is_socket_ready(int fd) const { return fd >= 0 && this->is_socket_ready_(fd); } #ifdef USE_WAKE_LOOP_THREADSAFE - /// Wake the main event loop from a FreeRTOS task - /// Thread-safe, can be called from task context to immediately wake select() - /// IMPORTANT: NOT safe to call from ISR context (socket operations not ISR-safe) + /// Wake the main event loop from a FreeRTOS task or ISR. + /// Thread-safe, can be called from any context to immediately wake the main loop. + /// On ESP32: uses xTaskNotifyGive (<1 us, ISR-safe) + /// On other platforms: uses UDP loopback socket (NOT ISR-safe) void wake_loop_threadsafe(); #endif #endif @@ -541,7 +542,7 @@ class Application { /// Perform a delay while also monitoring socket file descriptors for readiness void yield_with_select_(uint32_t delay_ms); -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) void setup_wake_loop_threadsafe_(); // Create wake notification socket inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined) #endif @@ -571,7 +572,7 @@ class Application { FixedVector looping_components_{}; #ifdef USE_SOCKET_SELECT_SUPPORT std::vector socket_fds_; // Vector of all monitored socket file descriptors -#ifdef USE_WAKE_LOOP_THREADSAFE +#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks #endif #endif @@ -584,7 +585,7 @@ class Application { uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) int max_fd_{-1}; // Highest file descriptor number for select() #endif @@ -600,14 +601,16 @@ class Application { bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) 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_ + fd_set read_fds_{}; // Working fd_set: populated by select() or direct rcvevent reads +#ifndef USE_ESP32 + fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes (select() path) +#endif #endif // StaticVectors (largest members - contain actual array data inline) @@ -694,7 +697,7 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) // Inline implementations for hot-path functions // drain_wake_notifications_() is called on every loop iteration @@ -716,6 +719,6 @@ inline void Application::drain_wake_notifications_() { } } } -#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) } // namespace esphome diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c new file mode 100644 index 0000000000..9a54b4ef04 --- /dev/null +++ b/esphome/core/lwip_fast_select.c @@ -0,0 +1,84 @@ +// Fast socket monitoring for ESP32 (ESP-IDF LwIP) +// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. +// +// This must be a .c file (not .cpp) because: +// 1. lwip/priv/sockets_priv.h conflicts with C++ compilation units that include bootloader headers +// 2. The netconn callback is a C function pointer +// +// defines.h is force-included by the build system (-include flag), providing USE_ESP32 etc. + +#ifdef USE_ESP32 + +// LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc. +#include +#include +#include +#include + +#include "esphome/core/lwip_fast_select.h" + +// Task handle for the main loop — set during setup, read from any thread/ISR +static TaskHandle_t s_main_loop_task = NULL; + +// Saved original event_callback pointer (same for all LwIP sockets) +static netconn_callback s_original_callback = NULL; + +// Wrapper callback: calls original event_callback + notifies main loop task. +// Called from LwIP's TCP/IP thread when socket events occur. +static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) { + // Call original LwIP event_callback first — updates rcvevent/sendevent/errevent, + // signals any select() waiters. This preserves all LwIP behavior. + if (s_original_callback) { + s_original_callback(conn, evt, len); + } + // Wake the main loop task if sleeping in ulTaskNotifyTake(). + // Only notify on receive events to avoid spurious wakeups from send-ready events. + // xTaskNotifyGive is thread-safe and ISR-safe, costs <1 us. + if (evt == NETCONN_EVT_RCVPLUS) { + TaskHandle_t task = s_main_loop_task; + if (task != NULL) { + xTaskNotifyGive(task); + } + } +} + +void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); } + +bool esphome_lwip_socket_has_data(int fd) { + struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); + return sock != NULL && sock->conn != NULL && sock->rcvevent > 0; +} + +void esphome_lwip_hook_socket(int fd) { + struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); + if (sock == NULL || sock->conn == NULL) + return; + + // Save original callback (only once — same event_callback for all LwIP sockets) + if (s_original_callback == NULL) { + s_original_callback = sock->conn->callback; + } + + // Replace with our wrapper + sock->conn->callback = esphome_socket_event_callback; +} + +void esphome_lwip_unhook_socket(int fd) { + struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); + if (sock == NULL || sock->conn == NULL) + return; + + // Restore original callback + if (s_original_callback != NULL) { + sock->conn->callback = s_original_callback; + } +} + +void esphome_lwip_wake_main_loop(void) { + TaskHandle_t task = s_main_loop_task; + if (task != NULL) { + xTaskNotifyGive(task); + } +} + +#endif // USE_ESP32 diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h new file mode 100644 index 0000000000..8bd2a052af --- /dev/null +++ b/esphome/core/lwip_fast_select.h @@ -0,0 +1,37 @@ +#pragma once + +// Fast socket monitoring for ESP32 (ESP-IDF LwIP) +// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. +// See fast_select.md for design rationale and benchmarks. + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// Initialize fast select — must be called from the main loop task during setup(). +/// Saves the current task handle for xTaskNotifyGive() wake notifications. +void esphome_lwip_fast_select_init(void); + +/// Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket). +/// Uses lwip_socket_dbg_get_socket() which is a direct array lookup — no locking, no refcount. +/// Safe for single-threaded polling from the main loop. +bool esphome_lwip_socket_has_data(int fd); + +/// Hook a socket's netconn callback to notify the main loop task on receive events. +/// Wraps the original event_callback with one that also calls xTaskNotifyGive(). +/// Must be called from the main loop after socket creation. +void esphome_lwip_hook_socket(int fd); + +/// Unhook a socket's netconn callback, restoring the original event_callback. +/// Must be called from the main loop before closing the socket. +void esphome_lwip_unhook_socket(int fd); + +/// Wake the main loop task from any thread or ISR — costs <1 us. +/// Replaces the UDP loopback socket wake mechanism. +void esphome_lwip_wake_main_loop(void); + +#ifdef __cplusplus +} +#endif From fd6d0de7a29acc5e072622e36219b3966fcdcd44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:00:00 -0600 Subject: [PATCH 12/31] cleanup --- esphome/components/socket/__init__.py | 5 ++- esphome/core/application.cpp | 11 +------ esphome/core/application.h | 31 ++++++++++++------- esphome/core/lwip_fast_select.c | 21 ++++++++++--- esphome/core/lwip_fast_select.h | 5 ++- .../socket/test_wake_loop_threadsafe.py | 8 +++++ 6 files changed, 48 insertions(+), 33 deletions(-) diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 06b0ba2bf7..4f6634bd7a 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -127,13 +127,12 @@ def require_wake_loop_threadsafe() -> None: Call this from components that need to wake the main event loop from background threads. - On ESP32: Uses FreeRTOS task notifications (<1 us, ISR-safe, no socket needed). + On ESP32: Uses FreeRTOS task notifications (<1 us, no socket needed). On other platforms: Uses a shared UDP loopback socket mechanism (~208 bytes RAM). This call is a no-op if networking is not enabled in the configuration. - On ESP32, this is safe to call from ISR context. - On other platforms, this is for background thread context only, NOT ISR context. + IMPORTANT: This is for background task context only, NOT ISR context. Example: from esphome.components import socket diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 9de5aa1e9e..8950e6029e 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -634,16 +634,7 @@ void Application::unregister_socket_fd(int fd) { void Application::yield_with_select_(uint32_t delay_ms) { // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run. #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) - // ESP32 fast path: direct rcvevent reads (~858 ns for 4 sockets vs 133 us for lwip_select) - if (!this->socket_fds_.empty()) [[likely]] { - FD_ZERO(&this->read_fds_); - for (int fd : this->socket_fds_) { - if (esphome_lwip_socket_has_data(fd)) { - FD_SET(fd, &this->read_fds_); - } - } - } - + // ESP32 fast path: no fd_set needed — is_socket_ready_() reads rcvevent directly (~215 ns per socket) if (delay_ms == 0) [[unlikely]] { yield(); return; diff --git a/esphome/core/application.h b/esphome/core/application.h index fba2ae54c2..5b087f59fe 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -24,10 +24,14 @@ #endif #ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_ESP32 +#include "esphome/core/lwip_fast_select.h" +#else #include -#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) +#ifdef USE_WAKE_LOOP_THREADSAFE #include #endif +#endif #endif // USE_SOCKET_SELECT_SUPPORT #ifdef USE_BINARY_SENSOR @@ -497,10 +501,10 @@ class Application { bool is_socket_ready(int fd) const { return fd >= 0 && this->is_socket_ready_(fd); } #ifdef USE_WAKE_LOOP_THREADSAFE - /// Wake the main event loop from a FreeRTOS task or ISR. - /// Thread-safe, can be called from any context to immediately wake the main loop. - /// On ESP32: uses xTaskNotifyGive (<1 us, ISR-safe) - /// On other platforms: uses UDP loopback socket (NOT ISR-safe) + /// Wake the main event loop from another FreeRTOS task. + /// Thread-safe, but must only be called from task context (NOT ISR-safe). + /// On ESP32: uses xTaskNotifyGive (<1 us) + /// On other platforms: uses UDP loopback socket void wake_loop_threadsafe(); #endif #endif @@ -513,8 +517,13 @@ class Application { /// Fast path for Socket::ready() via friendship - skips negative fd check. /// Safe because: fd was validated in register_socket_fd() at registration time, /// and Socket::ready() only calls this when loop_monitored_ is true (registration succeeded). - /// FD_ISSET may include its own upper bounds check depending on platform. +#ifdef USE_ESP32 + /// ESP32: direct rcvevent read — always fresh, no fd_set snapshot needed (~215 ns) + bool is_socket_ready_(int fd) const { return esphome_lwip_socket_has_data(fd); } +#else + /// Other platforms: check fd_set populated by select() bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } +#endif #endif void register_component_(Component *comp); @@ -605,12 +614,10 @@ class Application { 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 read_fds_{}; // Working fd_set: populated by select() or direct rcvevent reads -#ifndef USE_ESP32 - fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes (select() path) -#endif +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) + // Variable-sized members (not needed on ESP32 — is_socket_ready_ reads rcvevent directly) + fd_set read_fds_{}; // Working fd_set: populated by select() + fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes #endif // StaticVectors (largest members - contain actual array data inline) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 9a54b4ef04..8684909dc1 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -24,7 +24,7 @@ static TaskHandle_t s_main_loop_task = NULL; static netconn_callback s_original_callback = NULL; // Wrapper callback: calls original event_callback + notifies main loop task. -// Called from LwIP's TCP/IP thread when socket events occur. +// Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR). static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) { // Call original LwIP event_callback first — updates rcvevent/sendevent/errevent, // signals any select() waiters. This preserves all LwIP behavior. @@ -33,7 +33,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt } // Wake the main loop task if sleeping in ulTaskNotifyTake(). // Only notify on receive events to avoid spurious wakeups from send-ready events. - // xTaskNotifyGive is thread-safe and ISR-safe, costs <1 us. + // xTaskNotifyGive is safe from task context (LwIP TCP/IP thread). NOT ISR-safe. if (evt == NETCONN_EVT_RCVPLUS) { TaskHandle_t task = s_main_loop_task; if (task != NULL) { @@ -46,7 +46,13 @@ void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTas bool esphome_lwip_socket_has_data(int fd) { struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); - return sock != NULL && sock->conn != NULL && sock->rcvevent > 0; + if (sock == NULL || sock->conn == NULL) + return false; + // rcvevent is modified by LwIP's TCP/IP thread in event_callback. + // Use atomic load for C11 memory model correctness. On Xtensa/RISC-V (ESP32) + // aligned 16-bit reads are naturally atomic, so this compiles to a plain load + // but prevents compiler reordering. + return __atomic_load_n(&sock->rcvevent, __ATOMIC_RELAXED) > 0; } void esphome_lwip_hook_socket(int fd) { @@ -59,7 +65,10 @@ void esphome_lwip_hook_socket(int fd) { s_original_callback = sock->conn->callback; } - // Replace with our wrapper + // Replace with our wrapper. + // Thread safety: pointer writes are atomic on ESP32 (32-bit aligned). + // The TCP/IP thread may read conn->callback concurrently, but it will see + // either the old or new pointer — both are valid (our wrapper calls the original). sock->conn->callback = esphome_socket_event_callback; } @@ -68,7 +77,9 @@ void esphome_lwip_unhook_socket(int fd) { if (sock == NULL || sock->conn == NULL) return; - // Restore original callback + // Restore original callback. + // Thread safety: same as hook — pointer write is atomic on ESP32. + // TCP/IP thread sees either wrapper or original, both are safe. if (s_original_callback != NULL) { sock->conn->callback = s_original_callback; } diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 8bd2a052af..4c47c13f20 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -2,7 +2,6 @@ // Fast socket monitoring for ESP32 (ESP-IDF LwIP) // Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. -// See fast_select.md for design rationale and benchmarks. #include @@ -28,8 +27,8 @@ void esphome_lwip_hook_socket(int fd); /// Must be called from the main loop before closing the socket. void esphome_lwip_unhook_socket(int fd); -/// Wake the main loop task from any thread or ISR — costs <1 us. -/// Replaces the UDP loopback socket wake mechanism. +/// Wake the main loop task from another FreeRTOS task — costs <1 us. +/// NOT ISR-safe — must only be called from task context. void esphome_lwip_wake_main_loop(void); #ifdef __cplusplus diff --git a/tests/components/socket/test_wake_loop_threadsafe.py b/tests/components/socket/test_wake_loop_threadsafe.py index a40b6068a8..45d354f7b3 100644 --- a/tests/components/socket/test_wake_loop_threadsafe.py +++ b/tests/components/socket/test_wake_loop_threadsafe.py @@ -1,9 +1,16 @@ from esphome.components import socket +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_ESP8266 from esphome.core import CORE +def _setup_non_esp32_platform() -> None: + """Set up CORE.data with a non-ESP32 platform for testing.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP8266} + + def test_require_wake_loop_threadsafe__first_call() -> None: """Test that first call sets up define and consumes socket.""" + _setup_non_esp32_platform() CORE.config = {"wifi": True} socket.require_wake_loop_threadsafe() @@ -32,6 +39,7 @@ def test_require_wake_loop_threadsafe__idempotent() -> None: def test_require_wake_loop_threadsafe__multiple_calls() -> None: """Test that multiple calls only set up once.""" + _setup_non_esp32_platform() # Call three times CORE.config = {"openthread": True} socket.require_wake_loop_threadsafe() From 86642ab9d7499a04080b911e5f45392a040f4f09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:04:08 -0600 Subject: [PATCH 13/31] cleanup --- esphome/core/lwip_fast_select.c | 90 +++++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 8684909dc1..b2cf43b914 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -6,6 +6,52 @@ // 2. The netconn callback is a C function pointer // // defines.h is force-included by the build system (-include flag), providing USE_ESP32 etc. +// +// Thread safety analysis +// ====================== +// Three threads interact with this code: +// 1. Main loop task — calls init, has_data, hook, unhook +// 2. LwIP TCP/IP task — calls event_callback (which reads s_original_callback, writes rcvevent) +// 3. Background tasks — call wake_main_loop +// +// Shared state and safety rationale: +// +// s_main_loop_task (TaskHandle_t, 4 bytes): +// Written once by main loop in init(), before any hook/wake calls. +// Read by TCP/IP thread (in callback) and background tasks (in wake). +// Safe: write-once-then-read pattern. The init() call completes during setup() +// before any sockets are hooked, so all subsequent reads see the final value. +// +// s_original_callback (netconn_callback, 4-byte function pointer): +// Written by main loop in hook_socket() (only when NULL — set once). +// Read by TCP/IP thread in esphome_socket_event_callback(). +// Safe: set-once pattern. The first hook_socket() captures the original callback. +// All subsequent hooks see it already set and skip the write. The TCP/IP thread +// only reads this after the callback pointer has been swapped (which happens after +// the write), so it always sees the initialized value. +// +// sock->conn->callback (netconn_callback, 4-byte function pointer): +// Written by main loop in hook_socket() and unhook_socket(). +// Read by TCP/IP thread when invoking the callback. +// Safe: 32-bit aligned pointer writes are atomic on Xtensa and RISC-V (ESP32). +// The TCP/IP thread will see either the old or new pointer atomically — never a +// torn value. Both the wrapper and original callbacks are valid at all times +// (the wrapper itself calls the original), so either value is correct. +// +// sock->rcvevent (s16_t, 2 bytes): +// Written by TCP/IP thread in event_callback (via SYS_ARCH_INC/DEC under lock). +// Read by main loop in has_data(). +// Safe: aligned 16-bit reads are atomic on Xtensa/RISC-V. We use __atomic_load_n +// with __ATOMIC_RELAXED to prevent compiler reordering. Staleness is acceptable — +// a missed increment means we poll again next loop iteration (~16ms), and the +// task notification provides the real wake signal. +// +// FreeRTOS task notification value: +// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks +// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake). +// Safe: FreeRTOS notification APIs are thread-safe by design (use internal +// critical sections). Multiple concurrent xTaskNotifyGive calls are safe — +// the notification count simply increments. #ifdef USE_ESP32 @@ -17,10 +63,30 @@ #include "esphome/core/lwip_fast_select.h" -// Task handle for the main loop — set during setup, read from any thread/ISR +#include + +// Compile-time verification of thread safety assumptions. +// On ESP32 (Xtensa/RISC-V), naturally-aligned reads/writes up to 32 bits are atomic. +// These asserts ensure our cross-thread shared state meets those requirements. + +// Pointer types must fit in a single 32-bit store (atomic write) +_Static_assert(sizeof(TaskHandle_t) <= 4, "TaskHandle_t must be <= 4 bytes for atomic access"); +_Static_assert(sizeof(netconn_callback) <= 4, "netconn_callback must be <= 4 bytes for atomic access"); + +// rcvevent must fit in a single atomic read +_Static_assert(sizeof(((struct lwip_sock *) 0)->rcvevent) <= 4, "rcvevent must be <= 4 bytes for atomic access"); + +// Struct member alignment — natural alignment guarantees atomicity on Xtensa/RISC-V. +// Misaligned access would not be atomic even if the size is <= 4 bytes. +_Static_assert(offsetof(struct netconn, callback) % sizeof(netconn_callback) == 0, + "netconn.callback must be naturally aligned for atomic access"); +_Static_assert(offsetof(struct lwip_sock, rcvevent) % sizeof(((struct lwip_sock *) 0)->rcvevent) == 0, + "lwip_sock.rcvevent must be naturally aligned for atomic access"); + +// Task handle for the main loop — written once in init(), read from TCP/IP and background tasks. static TaskHandle_t s_main_loop_task = NULL; -// Saved original event_callback pointer (same for all LwIP sockets) +// Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; // Wrapper callback: calls original event_callback + notifies main loop task. @@ -33,7 +99,6 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt } // Wake the main loop task if sleeping in ulTaskNotifyTake(). // Only notify on receive events to avoid spurious wakeups from send-ready events. - // xTaskNotifyGive is safe from task context (LwIP TCP/IP thread). NOT ISR-safe. if (evt == NETCONN_EVT_RCVPLUS) { TaskHandle_t task = s_main_loop_task; if (task != NULL) { @@ -48,10 +113,8 @@ bool esphome_lwip_socket_has_data(int fd) { struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); if (sock == NULL || sock->conn == NULL) return false; - // rcvevent is modified by LwIP's TCP/IP thread in event_callback. - // Use atomic load for C11 memory model correctness. On Xtensa/RISC-V (ESP32) - // aligned 16-bit reads are naturally atomic, so this compiles to a plain load - // but prevents compiler reordering. + // Atomic load prevents compiler reordering. On ESP32 this compiles to a plain load + // since aligned 16-bit reads are naturally atomic on Xtensa/RISC-V. return __atomic_load_n(&sock->rcvevent, __ATOMIC_RELAXED) > 0; } @@ -60,15 +123,13 @@ void esphome_lwip_hook_socket(int fd) { if (sock == NULL || sock->conn == NULL) return; - // Save original callback (only once — same event_callback for all LwIP sockets) + // Save original callback once — all LwIP sockets share the same event_callback. if (s_original_callback == NULL) { s_original_callback = sock->conn->callback; } - // Replace with our wrapper. - // Thread safety: pointer writes are atomic on ESP32 (32-bit aligned). - // The TCP/IP thread may read conn->callback concurrently, but it will see - // either the old or new pointer — both are valid (our wrapper calls the original). + // Replace with our wrapper. Atomic on ESP32 (32-bit aligned pointer write). + // TCP/IP thread sees either old or new pointer — both are valid. sock->conn->callback = esphome_socket_event_callback; } @@ -77,14 +138,13 @@ void esphome_lwip_unhook_socket(int fd) { if (sock == NULL || sock->conn == NULL) return; - // Restore original callback. - // Thread safety: same as hook — pointer write is atomic on ESP32. - // TCP/IP thread sees either wrapper or original, both are safe. + // Restore original callback. Atomic on ESP32 (32-bit aligned pointer write). if (s_original_callback != NULL) { sock->conn->callback = s_original_callback; } } +// Wake the main loop from another FreeRTOS task. NOT ISR-safe. void esphome_lwip_wake_main_loop(void) { TaskHandle_t task = s_main_loop_task; if (task != NULL) { From 3e0cdc24041a7f50884115c3fd45d2ffad7fcab3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:10:38 -0600 Subject: [PATCH 14/31] cleanup --- esphome/components/socket/__init__.py | 10 ++++++---- esphome/core/application.cpp | 5 +---- esphome/core/lwip_fast_select.c | 16 +++------------- esphome/core/lwip_fast_select.h | 4 ---- 4 files changed, 10 insertions(+), 25 deletions(-) diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 4f6634bd7a..5f4d04eb44 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -126,13 +126,15 @@ def require_wake_loop_threadsafe() -> None: """Mark that wake_loop_threadsafe support is required by a component. Call this from components that need to wake the main event loop from background threads. - - On ESP32: Uses FreeRTOS task notifications (<1 us, no socket needed). - On other platforms: Uses a shared UDP loopback socket mechanism (~208 bytes RAM). + This enables the shared UDP loopback socket mechanism (~208 bytes RAM). + The socket is shared across all components that use this feature. This call is a no-op if networking is not enabled in the configuration. - IMPORTANT: This is for background task context only, NOT ISR context. + IMPORTANT: This is for background thread context only, NOT ISR context. + Socket operations are not safe to call from ISR handlers. + + On ESP32, FreeRTOS task notifications are used instead (no socket needed). Example: from esphome.components import socket diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 8950e6029e..29d3c9dac9 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -611,10 +611,7 @@ void Application::unregister_socket_fd(int fd) { if (i < this->socket_fds_.size() - 1) this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); -#ifdef USE_ESP32 - // Unhook the socket's netconn callback - esphome_lwip_unhook_socket(fd); -#else +#ifndef USE_ESP32 this->socket_fds_changed_ = true; // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index b2cf43b914..69703470f1 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -10,7 +10,7 @@ // Thread safety analysis // ====================== // Three threads interact with this code: -// 1. Main loop task — calls init, has_data, hook, unhook +// 1. Main loop task — calls init, has_data, hook // 2. LwIP TCP/IP task — calls event_callback (which reads s_original_callback, writes rcvevent) // 3. Background tasks — call wake_main_loop // @@ -31,7 +31,8 @@ // the write), so it always sees the initialized value. // // sock->conn->callback (netconn_callback, 4-byte function pointer): -// Written by main loop in hook_socket() and unhook_socket(). +// Written by main loop in hook_socket(). Never restored — all LwIP sockets share +// the same static event_callback, so the wrapper stays in place permanently. // Read by TCP/IP thread when invoking the callback. // Safe: 32-bit aligned pointer writes are atomic on Xtensa and RISC-V (ESP32). // The TCP/IP thread will see either the old or new pointer atomically — never a @@ -133,17 +134,6 @@ void esphome_lwip_hook_socket(int fd) { sock->conn->callback = esphome_socket_event_callback; } -void esphome_lwip_unhook_socket(int fd) { - struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); - if (sock == NULL || sock->conn == NULL) - return; - - // Restore original callback. Atomic on ESP32 (32-bit aligned pointer write). - if (s_original_callback != NULL) { - sock->conn->callback = s_original_callback; - } -} - // Wake the main loop from another FreeRTOS task. NOT ISR-safe. void esphome_lwip_wake_main_loop(void) { TaskHandle_t task = s_main_loop_task; diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 4c47c13f20..7d16042c03 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -23,10 +23,6 @@ bool esphome_lwip_socket_has_data(int fd); /// Must be called from the main loop after socket creation. void esphome_lwip_hook_socket(int fd); -/// Unhook a socket's netconn callback, restoring the original event_callback. -/// Must be called from the main loop before closing the socket. -void esphome_lwip_unhook_socket(int fd); - /// Wake the main loop task from another FreeRTOS task — costs <1 us. /// NOT ISR-safe — must only be called from task context. void esphome_lwip_wake_main_loop(void); From a696ed4920cce25d13042a74fe448a96605d2c7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:10:59 -0600 Subject: [PATCH 15/31] cleanup --- esphome/core/lwip_fast_select.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 69703470f1..f04ff3c2d8 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -114,9 +114,11 @@ bool esphome_lwip_socket_has_data(int fd) { struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); if (sock == NULL || sock->conn == NULL) return false; - // Atomic load prevents compiler reordering. On ESP32 this compiles to a plain load - // since aligned 16-bit reads are naturally atomic on Xtensa/RISC-V. - return __atomic_load_n(&sock->rcvevent, __ATOMIC_RELAXED) > 0; + // Use volatile to prevent compiler from caching/reordering this read. + // rcvevent is written by the TCP/IP thread under SYS_ARCH_PROTECT, not C11 atomics, + // so we match LwIP's own access pattern. Aligned 16-bit reads are naturally atomic + // on Xtensa/RISC-V. Staleness is acceptable — task notifications provide the real wake. + return *(volatile s16_t *) &sock->rcvevent > 0; } void esphome_lwip_hook_socket(int fd) { From c9c73ec6e97081737fa69c6eb55b532b85a6e4e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:13:16 -0600 Subject: [PATCH 16/31] cleanup --- esphome/core/lwip_fast_select.c | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index f04ff3c2d8..3169cf6997 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -41,11 +41,11 @@ // // sock->rcvevent (s16_t, 2 bytes): // Written by TCP/IP thread in event_callback (via SYS_ARCH_INC/DEC under lock). -// Read by main loop in has_data(). -// Safe: aligned 16-bit reads are atomic on Xtensa/RISC-V. We use __atomic_load_n -// with __ATOMIC_RELAXED to prevent compiler reordering. Staleness is acceptable — -// a missed increment means we poll again next loop iteration (~16ms), and the -// task notification provides the real wake signal. +// Read by main loop in has_data() via volatile cast. +// Safe: aligned 16-bit reads are atomic on Xtensa/RISC-V. The write side commits +// via SYS_ARCH_UNPROTECT (portEXIT_CRITICAL) which flushes the write buffer. +// ESP32 internal SRAM has no per-core data cache, so the volatile load always +// reads the committed value. volatile prevents compiler from caching the read. // // FreeRTOS task notification value: // Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks @@ -114,10 +114,11 @@ bool esphome_lwip_socket_has_data(int fd) { struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); if (sock == NULL || sock->conn == NULL) return false; - // Use volatile to prevent compiler from caching/reordering this read. - // rcvevent is written by the TCP/IP thread under SYS_ARCH_PROTECT, not C11 atomics, - // so we match LwIP's own access pattern. Aligned 16-bit reads are naturally atomic - // on Xtensa/RISC-V. Staleness is acceptable — task notifications provide the real wake. + // volatile prevents the compiler from caching/reordering this cross-thread read. + // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT (portEXIT_CRITICAL), + // which flushes the write buffer. ESP32 internal SRAM has no per-core data cache, + // so the volatile load always reads the committed value from SRAM. + // Aligned 16-bit reads are naturally atomic on Xtensa/RISC-V. return *(volatile s16_t *) &sock->rcvevent > 0; } From 3fd24b779c5d520aec313fd953a6fe32cbddfeaf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:17:50 -0600 Subject: [PATCH 17/31] comments --- esphome/core/lwip_fast_select.c | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 3169cf6997..7ae9bd5378 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -11,7 +11,8 @@ // ====================== // Three threads interact with this code: // 1. Main loop task — calls init, has_data, hook -// 2. LwIP TCP/IP task — calls event_callback (which reads s_original_callback, writes rcvevent) +// 2. LwIP TCP/IP task — calls event_callback (reads s_original_callback; writes rcvevent +// via the original callback under SYS_ARCH_PROTECT/UNPROTECT mutex) // 3. Background tasks — call wake_main_loop // // Shared state and safety rationale: @@ -40,12 +41,14 @@ // (the wrapper itself calls the original), so either value is correct. // // sock->rcvevent (s16_t, 2 bytes): -// Written by TCP/IP thread in event_callback (via SYS_ARCH_INC/DEC under lock). +// Written by TCP/IP thread in event_callback under SYS_ARCH_PROTECT/UNPROTECT. // Read by main loop in has_data() via volatile cast. -// Safe: aligned 16-bit reads are atomic on Xtensa/RISC-V. The write side commits -// via SYS_ARCH_UNPROTECT (portEXIT_CRITICAL) which flushes the write buffer. -// ESP32 internal SRAM has no per-core data cache, so the volatile load always -// reads the committed value. volatile prevents compiler from caching the read. +// Safe: SYS_ARCH_UNPROTECT releases a FreeRTOS mutex (sys_mutex_unlock), which +// internally uses a critical section with memory barrier (rsync on Xtensa), +// ensuring the write is committed before the mutex is released. The volatile +// cast prevents the compiler from caching the read. Aligned 16-bit reads are +// single-instruction loads on Xtensa (L16SI) and RISC-V (LH), which cannot +// produce torn values. // // FreeRTOS task notification value: // Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks @@ -115,10 +118,10 @@ bool esphome_lwip_socket_has_data(int fd) { if (sock == NULL || sock->conn == NULL) return false; // volatile prevents the compiler from caching/reordering this cross-thread read. - // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT (portEXIT_CRITICAL), - // which flushes the write buffer. ESP32 internal SRAM has no per-core data cache, - // so the volatile load always reads the committed value from SRAM. - // Aligned 16-bit reads are naturally atomic on Xtensa/RISC-V. + // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a + // FreeRTOS mutex with a memory barrier (rsync on Xtensa), ensuring the value is + // visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH) on + // Xtensa/RISC-V and cannot produce torn values. return *(volatile s16_t *) &sock->rcvevent > 0; } From 29416061ea226611e1cd91ff5e807fe3b6f7f3bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:20:38 -0600 Subject: [PATCH 18/31] comments --- esphome/core/application.cpp | 19 ++++---- esphome/core/lwip_fast_select.c | 8 +++- .../socket/test_wake_loop_threadsafe.py | 43 ++++++++++++++++--- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 29d3c9dac9..a8e39ded6e 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -147,14 +147,15 @@ void Application::setup() { clear_setup_priority_overrides(); #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) -#ifdef USE_ESP32 - // Initialize fast select: saves main loop task handle for xTaskNotifyGive wake +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) + // Initialize fast select: saves main loop task handle for xTaskNotifyGive wake. + // Always init on ESP32 — the fast path (rcvevent reads + ulTaskNotifyTake) is used + // unconditionally when USE_SOCKET_SELECT_SUPPORT is enabled. esphome_lwip_fast_select_init(); -#else - // Set up wake socket for waking main loop from tasks - this->setup_wake_loop_threadsafe_(); #endif +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) + // Set up wake socket for waking main loop from tasks (non-ESP32 only) + this->setup_wake_loop_threadsafe_(); #endif this->schedule_dump_config(); @@ -642,7 +643,9 @@ void Application::yield_with_select_(uint32_t delay_ms) { ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms)); #elif defined(USE_SOCKET_SELECT_SUPPORT) - // Non-ESP32 platforms (LibreTiny bk72xx/rtl87xx): use select() + // Non-ESP32 select() path (LibreTiny bk72xx/rtl87xx, host platform). + // ESP32 is excluded by the #if above — both BSD_SOCKETS and LWIP_SOCKETS on ESP32 + // use LwIP under the hood, so the fast path handles all ESP32 socket implementations. if (!this->socket_fds_.empty()) [[likely]] { // Update fd_set if socket list has changed if (this->socket_fds_changed_) [[unlikely]] { @@ -700,7 +703,7 @@ Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #ifdef USE_ESP32 void Application::wake_loop_threadsafe() { - // Direct FreeRTOS task notification — ISR-safe, <1 us + // Direct FreeRTOS task notification — <1 us, task context only (NOT ISR-safe) esphome_lwip_wake_main_loop(); } #else // !USE_ESP32 diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 7ae9bd5378..66e1f7c285 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -130,11 +130,17 @@ void esphome_lwip_hook_socket(int fd) { if (sock == NULL || sock->conn == NULL) return; - // Save original callback once — all LwIP sockets share the same event_callback. + // Save original callback once — all LwIP sockets share the same static event_callback + // (DEFAULT_SOCKET_EVENTCB in sockets.c, used for SOCK_RAW, SOCK_DGRAM, and SOCK_STREAM). if (s_original_callback == NULL) { s_original_callback = sock->conn->callback; } + // Verify assumption: if we already have the original, this socket should have the same one. + // If this fires, LwIP changed to use per-socket callbacks and we need a per-fd array. + LWIP_ASSERT("all sockets must share the same event_callback", + sock->conn->callback == s_original_callback || sock->conn->callback == esphome_socket_event_callback); + // Replace with our wrapper. Atomic on ESP32 (32-bit aligned pointer write). // TCP/IP thread sees either old or new pointer — both are valid. sock->conn->callback = esphome_socket_event_callback; diff --git a/tests/components/socket/test_wake_loop_threadsafe.py b/tests/components/socket/test_wake_loop_threadsafe.py index 45d354f7b3..28b4ee564f 100644 --- a/tests/components/socket/test_wake_loop_threadsafe.py +++ b/tests/components/socket/test_wake_loop_threadsafe.py @@ -1,16 +1,21 @@ from esphome.components import socket -from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_ESP8266 +from esphome.const import ( + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) from esphome.core import CORE -def _setup_non_esp32_platform() -> None: - """Set up CORE.data with a non-ESP32 platform for testing.""" - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP8266} +def _setup_platform(platform=PLATFORM_ESP8266) -> None: + """Set up CORE.data with a platform for testing.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} def test_require_wake_loop_threadsafe__first_call() -> None: """Test that first call sets up define and consumes socket.""" - _setup_non_esp32_platform() + _setup_platform() CORE.config = {"wifi": True} socket.require_wake_loop_threadsafe() @@ -39,7 +44,7 @@ def test_require_wake_loop_threadsafe__idempotent() -> None: def test_require_wake_loop_threadsafe__multiple_calls() -> None: """Test that multiple calls only set up once.""" - _setup_non_esp32_platform() + _setup_platform() # Call three times CORE.config = {"openthread": True} socket.require_wake_loop_threadsafe() @@ -83,3 +88,29 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() - udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) assert "socket.wake_loop_threadsafe" not in udp_consumers assert udp_consumers == initial_udp + + +def test_require_wake_loop_threadsafe__esp32_no_udp_socket() -> None: + """Test that ESP32 uses task notifications instead of UDP socket.""" + _setup_platform(PLATFORM_ESP32) + CORE.config = {"wifi": True} + socket.require_wake_loop_threadsafe() + + # Verify the define was added + assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True + assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines) + + # Verify no UDP socket was consumed (ESP32 uses FreeRTOS task notifications) + udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) + assert "socket.wake_loop_threadsafe" not in udp_consumers + + +def test_require_wake_loop_threadsafe__non_esp32_consumes_udp_socket() -> None: + """Test that non-ESP32 platforms consume a UDP socket for wake notifications.""" + _setup_platform(PLATFORM_ESP8266) + CORE.config = {"wifi": True} + socket.require_wake_loop_threadsafe() + + # Verify UDP socket was consumed + udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) + assert udp_consumers.get("socket.wake_loop_threadsafe") == 1 From c3dee3d30704d36a0ad20a306b6ae8e3d6695ed9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:21:43 -0600 Subject: [PATCH 19/31] comments --- esphome/core/lwip_fast_select.c | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 66e1f7c285..87baf449cb 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -136,11 +136,6 @@ void esphome_lwip_hook_socket(int fd) { s_original_callback = sock->conn->callback; } - // Verify assumption: if we already have the original, this socket should have the same one. - // If this fires, LwIP changed to use per-socket callbacks and we need a per-fd array. - LWIP_ASSERT("all sockets must share the same event_callback", - sock->conn->callback == s_original_callback || sock->conn->callback == esphome_socket_event_callback); - // Replace with our wrapper. Atomic on ESP32 (32-bit aligned pointer write). // TCP/IP thread sees either old or new pointer — both are valid. sock->conn->callback = esphome_socket_event_callback; From 6734aa1544ebb330159738a050c0acab354900a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:24:29 -0600 Subject: [PATCH 20/31] comments --- esphome/core/lwip_fast_select.c | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 87baf449cb..7995a60f8d 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -15,6 +15,21 @@ // via the original callback under SYS_ARCH_PROTECT/UNPROTECT mutex) // 3. Background tasks — call wake_main_loop // +// LwIP source references (STABLE-2_2_0_RELEASE): +// https://github.com/lwip-tcpip/lwip/blob/STABLE-2_2_0_RELEASE/src/api/sockets.c +// - event_callback (static, same for all sockets): line 619 +// - DEFAULT_SOCKET_EVENTCB = event_callback: line 622 +// - lwip_socket_dbg_get_socket (direct array lookup, no locking): line 654 +// - tryget_socket_unconn_nouse (the array lookup helper): line 1008 +// - All socket types use DEFAULT_SOCKET_EVENTCB: lines 3309-3325 +// - event_callback SYS_ARCH_PROTECT before rcvevent switch: line 3685 +// - sock->rcvevent++ (NETCONN_EVT_RCVPLUS case): line 3688 +// - SYS_ARCH_UNPROTECT after switch: line 3720 +// https://github.com/lwip-tcpip/lwip/blob/STABLE-2_2_0_RELEASE/src/include/lwip/sys.h +// - SYS_ARCH_PROTECT calls sys_arch_protect(): line 557 +// - SYS_ARCH_UNPROTECT calls sys_arch_unprotect(): line 568 +// (ESP-IDF implements sys_arch_protect/unprotect as FreeRTOS mutex lock/unlock) +// // Shared state and safety rationale: // // s_main_loop_task (TaskHandle_t, 4 bytes): @@ -33,7 +48,7 @@ // // sock->conn->callback (netconn_callback, 4-byte function pointer): // Written by main loop in hook_socket(). Never restored — all LwIP sockets share -// the same static event_callback, so the wrapper stays in place permanently. +// the same static event_callback (line 619, 622), so the wrapper stays permanently. // Read by TCP/IP thread when invoking the callback. // Safe: 32-bit aligned pointer writes are atomic on Xtensa and RISC-V (ESP32). // The TCP/IP thread will see either the old or new pointer atomically — never a @@ -41,14 +56,13 @@ // (the wrapper itself calls the original), so either value is correct. // // sock->rcvevent (s16_t, 2 bytes): -// Written by TCP/IP thread in event_callback under SYS_ARCH_PROTECT/UNPROTECT. +// Written by TCP/IP thread in event_callback under SYS_ARCH_PROTECT (line 3685). // Read by main loop in has_data() via volatile cast. -// Safe: SYS_ARCH_UNPROTECT releases a FreeRTOS mutex (sys_mutex_unlock), which -// internally uses a critical section with memory barrier (rsync on Xtensa), -// ensuring the write is committed before the mutex is released. The volatile -// cast prevents the compiler from caching the read. Aligned 16-bit reads are -// single-instruction loads on Xtensa (L16SI) and RISC-V (LH), which cannot -// produce torn values. +// Safe: SYS_ARCH_UNPROTECT (line 3720) releases a FreeRTOS mutex, which internally +// uses a critical section with memory barrier (rsync on Xtensa), ensuring the write +// is committed before the mutex is released. The volatile cast prevents the compiler +// from caching the read. Aligned 16-bit reads are single-instruction loads on +// Xtensa (L16SI) and RISC-V (LH), which cannot produce torn values. // // FreeRTOS task notification value: // Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks From 661f826bf166b7e94dfc506a706328299bfba7dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:27:03 -0600 Subject: [PATCH 21/31] comments --- esphome/core/lwip_fast_select.c | 4 ++++ esphome/core/lwip_fast_select.h | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 7995a60f8d..cdbd32c8e6 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -128,6 +128,10 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); } bool esphome_lwip_socket_has_data(int fd) { + // lwip_socket_dbg_get_socket() is a direct array lookup without the refcount that + // get_socket()/done_socket() uses. This is safe because the caller owns the socket + // lifetime: both has_data() and socket close happen on the main loop thread, so + // the sockets[] entry cannot be freed while we read it. struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); if (sock == NULL || sock->conn == NULL) return false; diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 7d16042c03..73a89fdc3d 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -14,8 +14,9 @@ extern "C" { void esphome_lwip_fast_select_init(void); /// Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket). -/// Uses lwip_socket_dbg_get_socket() which is a direct array lookup — no locking, no refcount. -/// Safe for single-threaded polling from the main loop. +/// Uses lwip_socket_dbg_get_socket() — a direct array lookup without the refcount that +/// get_socket()/done_socket() uses. Safe because the caller owns the socket lifetime: +/// both has_data reads and socket close/unregister happen on the main loop thread. bool esphome_lwip_socket_has_data(int fd); /// Hook a socket's netconn callback to notify the main loop task on receive events. From 746e760697713d61ddc0b76fa44dc5e41f3e804a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:32:18 -0600 Subject: [PATCH 22/31] Remove dead NULL check on s_original_callback --- esphome/core/lwip_fast_select.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index cdbd32c8e6..79464e0038 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -112,9 +112,9 @@ static netconn_callback s_original_callback = NULL; static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) { // Call original LwIP event_callback first — updates rcvevent/sendevent/errevent, // signals any select() waiters. This preserves all LwIP behavior. - if (s_original_callback) { - s_original_callback(conn, evt, len); - } + // s_original_callback is always valid here: hook_socket() sets it before swapping + // the callback pointer, so this wrapper cannot run until it's initialized. + s_original_callback(conn, evt, len); // Wake the main loop task if sleeping in ulTaskNotifyTake(). // Only notify on receive events to avoid spurious wakeups from send-ready events. if (evt == NETCONN_EVT_RCVPLUS) { From 1c886d43e7d7a4a825a6f45f8f700247fcd1f204 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:33:05 -0600 Subject: [PATCH 23/31] Extract get_sock() helper to deduplicate lookup pattern --- esphome/core/lwip_fast_select.c | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 79464e0038..cff305fff5 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -127,13 +127,21 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); } -bool esphome_lwip_socket_has_data(int fd) { - // lwip_socket_dbg_get_socket() is a direct array lookup without the refcount that - // get_socket()/done_socket() uses. This is safe because the caller owns the socket - // lifetime: both has_data() and socket close happen on the main loop thread, so - // the sockets[] entry cannot be freed while we read it. +// lwip_socket_dbg_get_socket() is a direct array lookup without the refcount that +// get_socket()/done_socket() uses. This is safe because the caller owns the socket +// lifetime: both has_data() and socket close happen on the main loop thread, so +// the sockets[] entry cannot be freed while we read it. +// Returns the sock only if both the sock and its netconn are valid, NULL otherwise. +static inline struct lwip_sock *get_sock(int fd) { struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); if (sock == NULL || sock->conn == NULL) + return NULL; + return sock; +} + +bool esphome_lwip_socket_has_data(int fd) { + struct lwip_sock *sock = get_sock(fd); + if (sock == NULL) return false; // volatile prevents the compiler from caching/reordering this cross-thread read. // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a @@ -144,8 +152,8 @@ bool esphome_lwip_socket_has_data(int fd) { } void esphome_lwip_hook_socket(int fd) { - struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); - if (sock == NULL || sock->conn == NULL) + struct lwip_sock *sock = get_sock(fd); + if (sock == NULL) return; // Save original callback once — all LwIP sockets share the same static event_callback From 8d1cf9fd7d2dbe03d9ea884611a45e24ad9efce1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:37:19 -0600 Subject: [PATCH 24/31] Add clarifying comments for review feedback --- esphome/core/application.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index a8e39ded6e..23764d81e6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -608,7 +608,9 @@ void Application::unregister_socket_fd(int fd) { if (this->socket_fds_[i] != fd) continue; - // Swap with last element and pop - O(1) removal since order doesn't matter + // Swap with last element and pop - O(1) removal since order doesn't matter. + // No need to unhook the netconn callback on ESP32 — all LwIP sockets share + // the same static event_callback, and the socket will be closed by the caller. if (i < this->socket_fds_.size() - 1) this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); @@ -640,6 +642,8 @@ void Application::yield_with_select_(uint32_t delay_ms) { // Sleep with instant wake via FreeRTOS task notification. // Woken by: callback wrapper (socket data arrives), wake_loop_threadsafe() (other tasks), or timeout. + // Without USE_WAKE_LOOP_THREADSAFE, only hooked socket callbacks wake the task — + // background tasks won't call wake, so this degrades to a pure timeout (same as old select path). ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms)); #elif defined(USE_SOCKET_SELECT_SUPPORT) From cba004e2d1398e094b804ab141e63c0e8864e086 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 21:49:26 -0600 Subject: [PATCH 25/31] Check for pending socket data before sleeping --- esphome/core/application.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 23764d81e6..b0457d15ec 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -640,6 +640,17 @@ void Application::yield_with_select_(uint32_t delay_ms) { return; } + // Check if any socket already has pending data before sleeping. + // If a socket still has unread data (rcvevent > 0) but the task notification was already + // consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency. + // This scan preserves select() semantics: return immediately when any fd is ready. + for (int fd : this->socket_fds_) { + if (esphome_lwip_socket_has_data(fd)) { + yield(); + return; + } + } + // Sleep with instant wake via FreeRTOS task notification. // Woken by: callback wrapper (socket data arrives), wake_loop_threadsafe() (other tasks), or timeout. // Without USE_WAKE_LOOP_THREADSAFE, only hooked socket callbacks wake the task — From 04af37e51404985d0208912cbb8ec33cea8aaef0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 22:01:36 -0600 Subject: [PATCH 26/31] Note lwip_socket_dbg_get_socket wraps tryget_socket_unconn_nouse --- esphome/core/lwip_fast_select.c | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index cff305fff5..6871d0bfa2 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -127,10 +127,12 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); } -// lwip_socket_dbg_get_socket() is a direct array lookup without the refcount that -// get_socket()/done_socket() uses. This is safe because the caller owns the socket -// lifetime: both has_data() and socket close happen on the main loop thread, so -// the sockets[] entry cannot be freed while we read it. +// lwip_socket_dbg_get_socket() is a thin wrapper around the static +// tryget_socket_unconn_nouse() — a direct array lookup without the refcount +// that get_socket()/done_socket() uses. This is safe because the caller owns +// the socket lifetime: both has_data() and socket close happen on the main +// loop thread, so the sockets[] entry cannot be freed while we read it. +// If lwip_socket_dbg_get_socket() were ever removed, we could fall back to lwip_select(). // Returns the sock only if both the sock and its netconn are valid, NULL otherwise. static inline struct lwip_sock *get_sock(int fd) { struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); From 5a378143e294c82d5289b0ce450c199ceddf1909 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 22:06:17 -0600 Subject: [PATCH 27/31] Fix LwIP source references and thread safety comments --- esphome/core/lwip_fast_select.c | 47 ++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 6871d0bfa2..caee6cffce 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -15,28 +15,30 @@ // via the original callback under SYS_ARCH_PROTECT/UNPROTECT mutex) // 3. Background tasks — call wake_main_loop // -// LwIP source references (STABLE-2_2_0_RELEASE): -// https://github.com/lwip-tcpip/lwip/blob/STABLE-2_2_0_RELEASE/src/api/sockets.c -// - event_callback (static, same for all sockets): line 619 -// - DEFAULT_SOCKET_EVENTCB = event_callback: line 622 -// - lwip_socket_dbg_get_socket (direct array lookup, no locking): line 654 -// - tryget_socket_unconn_nouse (the array lookup helper): line 1008 -// - All socket types use DEFAULT_SOCKET_EVENTCB: lines 3309-3325 -// - event_callback SYS_ARCH_PROTECT before rcvevent switch: line 3685 -// - sock->rcvevent++ (NETCONN_EVT_RCVPLUS case): line 3688 -// - SYS_ARCH_UNPROTECT after switch: line 3720 -// https://github.com/lwip-tcpip/lwip/blob/STABLE-2_2_0_RELEASE/src/include/lwip/sys.h -// - SYS_ARCH_PROTECT calls sys_arch_protect(): line 557 -// - SYS_ARCH_UNPROTECT calls sys_arch_unprotect(): line 568 +// LwIP source references (ESP-IDF v5.5.2, commit 30aaf64524): +// sockets.c: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/api/sockets.c +// - event_callback (static, same for all sockets): L327 +// - DEFAULT_SOCKET_EVENTCB = event_callback: L328 +// - tryget_socket_unconn_nouse (direct array lookup): L450 +// - lwip_socket_dbg_get_socket (thin wrapper): L461 +// - All socket types use DEFAULT_SOCKET_EVENTCB: L1741, L1748, L1759 +// - event_callback definition: L2538 +// - SYS_ARCH_PROTECT before rcvevent switch: L2578 +// - sock->rcvevent++ (NETCONN_EVT_RCVPLUS case): L2582 +// - SYS_ARCH_UNPROTECT after switch: L2615 +// sys.h: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/include/lwip/sys.h +// - SYS_ARCH_PROTECT calls sys_arch_protect(): L495 +// - SYS_ARCH_UNPROTECT calls sys_arch_unprotect(): L506 // (ESP-IDF implements sys_arch_protect/unprotect as FreeRTOS mutex lock/unlock) // // Shared state and safety rationale: // // s_main_loop_task (TaskHandle_t, 4 bytes): -// Written once by main loop in init(), before any hook/wake calls. -// Read by TCP/IP thread (in callback) and background tasks (in wake). -// Safe: write-once-then-read pattern. The init() call completes during setup() -// before any sockets are hooked, so all subsequent reads see the final value. +// Written once by main loop in init(). Read by TCP/IP thread (in callback) +// and background tasks (in wake). +// Safe: write-once-then-read pattern. Socket hooks may run before init(), +// but the NULL check on s_main_loop_task in the callback provides correct +// degraded behavior — notifications are simply skipped until init() completes. // // s_original_callback (netconn_callback, 4-byte function pointer): // Written by main loop in hook_socket() (only when NULL — set once). @@ -48,7 +50,7 @@ // // sock->conn->callback (netconn_callback, 4-byte function pointer): // Written by main loop in hook_socket(). Never restored — all LwIP sockets share -// the same static event_callback (line 619, 622), so the wrapper stays permanently. +// the same static event_callback (DEFAULT_SOCKET_EVENTCB), so the wrapper stays permanently. // Read by TCP/IP thread when invoking the callback. // Safe: 32-bit aligned pointer writes are atomic on Xtensa and RISC-V (ESP32). // The TCP/IP thread will see either the old or new pointer atomically — never a @@ -56,11 +58,12 @@ // (the wrapper itself calls the original), so either value is correct. // // sock->rcvevent (s16_t, 2 bytes): -// Written by TCP/IP thread in event_callback under SYS_ARCH_PROTECT (line 3685). +// Written by TCP/IP thread in event_callback under SYS_ARCH_PROTECT. // Read by main loop in has_data() via volatile cast. -// Safe: SYS_ARCH_UNPROTECT (line 3720) releases a FreeRTOS mutex, which internally -// uses a critical section with memory barrier (rsync on Xtensa), ensuring the write -// is committed before the mutex is released. The volatile cast prevents the compiler +// Safe: SYS_ARCH_UNPROTECT releases a FreeRTOS mutex, which internally +// uses a critical section with memory barrier (rsync on dual-core Xtensa; on +// single-core builds the spinlock is compiled out, but cross-core visibility is +// not an issue). The volatile cast prevents the compiler // from caching the read. Aligned 16-bit reads are single-instruction loads on // Xtensa (L16SI) and RISC-V (LH), which cannot produce torn values. // From 63a144fb437e1929bdf5e009e9db25e8bd6155f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 22:07:58 -0600 Subject: [PATCH 28/31] Fix is_socket_ready comment: main loop only, not thread-safe --- esphome/core/application.h | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 5b087f59fe..e9153beda8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -495,9 +495,10 @@ class Application { /// @return true if registration was successful, false if fd exceeds limits bool register_socket_fd(int fd); void unregister_socket_fd(int fd); - /// Check if there's data available on a socket without blocking - /// This function is thread-safe for reading, but should be called after select() has run - /// The read_fds_ is only modified by select() in the main loop + /// Check if there's data available on a socket without blocking. + /// Must only be called from the main loop thread — on ESP32, the underlying + /// lwip_socket_dbg_get_socket() has no refcount, so socket lifetime safety + /// depends on both reads and close happening on the same thread. bool is_socket_ready(int fd) const { return fd >= 0 && this->is_socket_ready_(fd); } #ifdef USE_WAKE_LOOP_THREADSAFE From 341395a1cd6a991cd89cd026358fcd0ad25d6c22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 22:09:46 -0600 Subject: [PATCH 29/31] Remove unused public is_socket_ready() --- esphome/core/application.h | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index e9153beda8..9bd4c99a6e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -495,11 +495,6 @@ class Application { /// @return true if registration was successful, false if fd exceeds limits bool register_socket_fd(int fd); void unregister_socket_fd(int fd); - /// Check if there's data available on a socket without blocking. - /// Must only be called from the main loop thread — on ESP32, the underlying - /// lwip_socket_dbg_get_socket() has no refcount, so socket lifetime safety - /// depends on both reads and close happening on the same thread. - bool is_socket_ready(int fd) const { return fd >= 0 && this->is_socket_ready_(fd); } #ifdef USE_WAKE_LOOP_THREADSAFE /// Wake the main event loop from another FreeRTOS task. @@ -715,8 +710,8 @@ static constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; inline void Application::drain_wake_notifications_() { // Called from main loop to drain any pending wake notifications - // Must check is_socket_ready() to avoid blocking on empty socket - if (this->wake_socket_fd_ >= 0 && this->is_socket_ready(this->wake_socket_fd_)) { + // Must check is_socket_ready_() to avoid blocking on empty socket + if (this->wake_socket_fd_ >= 0 && this->is_socket_ready_(this->wake_socket_fd_)) { char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; // Drain all pending notifications with non-blocking reads // Multiple wake events may have triggered multiple writes, so drain until EWOULDBLOCK From 44462d8453f9d267dc867ed820e50f25ea9d3feb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 22:10:29 -0600 Subject: [PATCH 30/31] Document Socket::ready() as main-loop-only --- esphome/components/socket/socket.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index c0098d689a..a771e2fe1a 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -71,7 +71,7 @@ class Socket { int get_fd() const { return -1; } #endif - /// Check if socket has data ready to read + /// Check if socket has data ready to read. Must only be called from the main loop thread. /// For select()-based sockets: non-virtual, checks Application's select() results /// For LWIP raw TCP sockets: virtual, checks internal buffer state #ifdef USE_SOCKET_SELECT_SUPPORT From d1dffafc861f519f248d200ee877fcfa7a1f05b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 22:14:15 -0600 Subject: [PATCH 31/31] Clarify rcvevent reads are main-loop-only due to socket ownership --- esphome/core/application.cpp | 3 ++- esphome/core/application.h | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b0457d15ec..1a1dfc3233 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -634,7 +634,8 @@ void Application::unregister_socket_fd(int fd) { void Application::yield_with_select_(uint32_t delay_ms) { // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run. #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) - // ESP32 fast path: no fd_set needed — is_socket_ready_() reads rcvevent directly (~215 ns per socket) + // ESP32 fast path: reads rcvevent directly via lwip_socket_dbg_get_socket() (~215 ns per socket). + // Safe because this runs on the main loop which owns socket lifetime (create, read, close). if (delay_ms == 0) [[unlikely]] { yield(); return; diff --git a/esphome/core/application.h b/esphome/core/application.h index 9bd4c99a6e..f5df5e7bdf 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -511,13 +511,12 @@ class Application { #ifdef USE_SOCKET_SELECT_SUPPORT /// Fast path for Socket::ready() via friendship - skips negative fd check. - /// Safe because: fd was validated in register_socket_fd() at registration time, - /// and Socket::ready() only calls this when loop_monitored_ is true (registration succeeded). + /// Main loop only — on ESP32, reads rcvevent via lwip_socket_dbg_get_socket() + /// which has no refcount; safe only because the main loop owns socket lifetime + /// (creates, reads, and closes sockets on the same thread). #ifdef USE_ESP32 - /// ESP32: direct rcvevent read — always fresh, no fd_set snapshot needed (~215 ns) bool is_socket_ready_(int fd) const { return esphome_lwip_socket_has_data(fd); } #else - /// Other platforms: check fd_set populated by select() bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } #endif #endif