From 2bf79a607f4695848a9c7cff795161f29b5bd384 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Sep 2025 15:55:14 -0400 Subject: [PATCH 01/11] [sx126x] Fix issues with variable length FSK packets (#10911) --- esphome/components/sx126x/sx126x.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index cae047d168..f5393c478a 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -217,7 +217,7 @@ void SX126x::configure() { this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 4); // set packet params and sync word - this->set_packet_params_(this->payload_length_); + this->set_packet_params_(this->get_max_packet_size()); if (this->sync_value_.size() == 2) { this->write_register_(REG_LORA_SYNCWORD, this->sync_value_.data(), this->sync_value_.size()); } @@ -236,7 +236,7 @@ void SX126x::configure() { this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 8); // set packet params and sync word - this->set_packet_params_(this->payload_length_); + this->set_packet_params_(this->get_max_packet_size()); if (!this->sync_value_.empty()) { this->write_register_(REG_GFSK_SYNCWORD, this->sync_value_.data(), this->sync_value_.size()); } @@ -274,7 +274,7 @@ void SX126x::set_packet_params_(uint8_t payload_length) { buf[2] = (this->preamble_detect_ > 0) ? ((this->preamble_detect_ - 1) | 0x04) : 0x00; buf[3] = this->sync_value_.size() * 8; buf[4] = 0x00; - buf[5] = 0x00; + buf[5] = (this->payload_length_ > 0) ? 0x00 : 0x01; buf[6] = payload_length; buf[7] = this->crc_enable_ ? 0x06 : 0x01; buf[8] = 0x00; @@ -314,6 +314,9 @@ SX126xError SX126x::transmit_packet(const std::vector &packet) { buf[0] = 0xFF; buf[1] = 0xFF; this->write_opcode_(RADIO_CLR_IRQSTATUS, buf, 2); + if (this->payload_length_ == 0) { + this->set_packet_params_(this->get_max_packet_size()); + } if (this->rx_start_) { this->set_mode_rx(); } else { From 9dd6be4061c8b6a47362d42debb66050bb35769c Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 27 Sep 2025 17:50:18 -0500 Subject: [PATCH 02/11] [zwave_proxy, api] Add notification message when Z-Wave HomeID changes (#10860) --- esphome/components/api/api.proto | 4 ++- esphome/components/api/api_pb2.cpp | 21 +++++++++++ esphome/components/api/api_pb2.h | 8 ++++- esphome/components/api/api_pb2_dump.cpp | 5 +++ esphome/components/api/api_server.cpp | 9 +++++ esphome/components/api/api_server.h | 3 ++ .../components/zwave_proxy/zwave_proxy.cpp | 36 +++++++++++++++---- esphome/components/zwave_proxy/zwave_proxy.h | 1 + 8 files changed, 79 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 9273cca2d3..0e385c4a17 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2310,11 +2310,13 @@ message ZWaveProxyFrame { enum ZWaveProxyRequestType { ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; + ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2; } message ZWaveProxyRequest { option (id) = 129; - option (source) = SOURCE_CLIENT; + option (source) = SOURCE_BOTH; option (ifdef) = "USE_ZWAVE_PROXY"; ZWaveProxyRequestType type = 1; + bytes data = 2 [(pointer_to_buffer) = true]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 410ba2334e..0140c60e5b 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3112,6 +3112,27 @@ bool ZWaveProxyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } return true; } +bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 2: { + // Use raw data directly to avoid allocation + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; + } + return true; +} +void ZWaveProxyRequest::encode(ProtoWriteBuffer buffer) const { + buffer.encode_uint32(1, static_cast(this->type)); + buffer.encode_bytes(2, this->data, this->data_len); +} +void ZWaveProxyRequest::calculate_size(ProtoSize &size) const { + size.add_uint32(1, static_cast(this->type)); + size.add_length(2, this->data_len); +} #endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index ee8472b21c..d71ee9777d 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -280,6 +280,7 @@ enum UpdateCommand : uint32_t { enum ZWaveProxyRequestType : uint32_t { ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0, ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1, + ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2, }; #endif @@ -2971,16 +2972,21 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { class ZWaveProxyRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 129; - static constexpr uint8_t ESTIMATED_SIZE = 2; + static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "z_wave_proxy_request"; } #endif enums::ZWaveProxyRequestType type{}; + const uint8_t *data{nullptr}; + uint16_t data_len{0}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index a5494168f9..c5f1d99dd4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -662,6 +662,8 @@ template<> const char *proto_enum_to_string(enums: return "ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE"; case enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: return "ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE"; + case enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE: + return "ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE"; default: return "UNKNOWN"; } @@ -2161,6 +2163,9 @@ void ZWaveProxyFrame::dump_to(std::string &out) const { void ZWaveProxyRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ZWaveProxyRequest"); dump_field(out, "type", static_cast(this->type)); + out.append(" data: "); + out.append(format_hex_pretty(this->data, this->data_len)); + out.append("\n"); } #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ff94b92b5d..1d5c06092f 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -356,6 +356,15 @@ void APIServer::on_update(update::UpdateEntity *obj) { } #endif +#ifdef USE_ZWAVE_PROXY +void APIServer::on_zwave_proxy_request(const esphome::api::ProtoMessage &msg) { + // We could add code to manage a second subscription type, but, since this message type is + // very infrequent and small, we simply send it to all clients + for (auto &c : this->clients_) + c->send_message(msg, api::ZWaveProxyRequest::MESSAGE_TYPE); +} +#endif + #ifdef USE_ALARM_CONTROL_PANEL API_DISPATCH_UPDATE(alarm_control_panel::AlarmControlPanel, alarm_control_panel) #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 354c764825..627870af1d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -126,6 +126,9 @@ class APIServer : public Component, public Controller { #ifdef USE_UPDATE void on_update(update::UpdateEntity *obj) override; #endif +#ifdef USE_ZWAVE_PROXY + void on_zwave_proxy_request(const esphome::api::ProtoMessage &msg); +#endif bool is_connected() const; diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index feaf6e2d42..70932da87c 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -1,4 +1,5 @@ #include "zwave_proxy.h" +#include "esphome/components/api/api_server.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -97,12 +98,19 @@ void ZWaveProxy::process_uart_() { // - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS) if (this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS && this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) { - // Extract the 4-byte Home ID starting at offset 4 + // Store the 4-byte Home ID, which starts at offset 4, and notify connected clients if it changed // The frame parser has already validated the checksum and ensured all bytes are present - std::memcpy(this->home_id_.data(), this->buffer_.data() + 4, this->home_id_.size()); - this->home_id_ready_ = true; - ESP_LOGI(TAG, "Home ID: %s", - format_hex_pretty(this->home_id_.data(), this->home_id_.size(), ':', false).c_str()); + if (this->set_home_id(&this->buffer_[4])) { + api::ZWaveProxyRequest msg; + msg.type = api::enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE; + msg.data = this->home_id_.data(); + msg.data_len = this->home_id_.size(); + if (api::global_api_server != nullptr) { + // We could add code to manage a second subscription type, but, since this message is + // very infrequent and small, we simply send it to all clients + api::global_api_server->on_zwave_proxy_request(msg); + } + } } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { @@ -120,7 +128,12 @@ void ZWaveProxy::process_uart_() { } } -void ZWaveProxy::dump_config() { ESP_LOGCONFIG(TAG, "Z-Wave Proxy"); } +void ZWaveProxy::dump_config() { + ESP_LOGCONFIG(TAG, + "Z-Wave Proxy:\n" + " Home ID: %s", + format_hex_pretty(this->home_id_.data(), this->home_id_.size(), ':', false).c_str()); +} void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) { switch (type) { @@ -145,6 +158,17 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en } } +bool ZWaveProxy::set_home_id(const uint8_t *new_home_id) { + if (std::memcmp(this->home_id_.data(), new_home_id, this->home_id_.size()) == 0) { + ESP_LOGV(TAG, "Home ID unchanged"); + return false; // No change + } + std::memcpy(this->home_id_.data(), new_home_id, this->home_id_.size()); + ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty(this->home_id_.data(), this->home_id_.size(), ':', false).c_str()); + this->home_id_ready_ = true; + return true; // Home ID was changed +} + void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { if (length == 1 && data[0] == this->last_response_) { ESP_LOGV(TAG, "Skipping sending duplicate response: 0x%02X", data[0]); diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index ea6837888b..a9123a81ca 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -56,6 +56,7 @@ class ZWaveProxy : public uart::UARTDevice, public Component { uint32_t get_home_id() { return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]); } + bool set_home_id(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. void send_frame(const uint8_t *data, size_t length); From 3f03e8c42324e7a79ed146110676fd5c502d7efc Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Sun, 28 Sep 2025 09:15:18 +0200 Subject: [PATCH 03/11] [usb_host][usb_uart] Allow on ESP32-P4 (#10815) --- esphome/components/usb_host/__init__.py | 3 ++- esphome/components/usb_host/usb_host.h | 2 +- esphome/components/usb_host/usb_host_client.cpp | 2 +- esphome/components/usb_host/usb_host_component.cpp | 2 +- esphome/components/usb_uart/__init__.py | 1 - esphome/components/usb_uart/ch34x.cpp | 2 +- esphome/components/usb_uart/cp210x.cpp | 2 +- esphome/components/usb_uart/usb_uart.cpp | 7 ++++--- esphome/components/usb_uart/usb_uart.h | 2 +- 9 files changed, 12 insertions(+), 11 deletions(-) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 0fe3310127..de734bf425 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.esp32 import ( + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, add_idf_sdkconfig_option, @@ -47,7 +48,7 @@ CONFIG_SCHEMA = cv.All( } ), cv.only_with_esp_idf, - only_on_variant(supported=[VARIANT_ESP32S2, VARIANT_ESP32S3]), + only_on_variant(supported=[VARIANT_ESP32S2, VARIANT_ESP32S3, VARIANT_ESP32P4]), ) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index d2ff4da068..8cf313aa9b 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -1,7 +1,7 @@ #pragma once // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "esphome/core/component.h" #include #include "usb/usb_host.h" diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 5c9d56c7f9..a9d4d42a8c 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_host.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 682026a9c5..fb19239c73 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_host.h" #include #include "esphome/core/log.h" diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 6999b1b955..a852e1f78b 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -24,7 +24,6 @@ usb_uart_ns = cg.esphome_ns.namespace("usb_uart") USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component) USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent) - UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 601bfe7366..7a0cc537fb 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 35834c7529..5fec0bed02 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 8603e28d62..a8a8bc231c 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_uart.h" #include "esphome/core/log.h" #include "esphome/components/uart/uart_debugger.h" @@ -297,7 +297,8 @@ static void fix_mps(const usb_ep_desc_t *ep) { if (ep != nullptr) { auto *ep_mutable = const_cast(ep); if (ep->wMaxPacketSize > 64) { - ESP_LOGW(TAG, "Corrected MPS of EP %u from %u to 64", ep->bEndpointAddress, ep->wMaxPacketSize); + ESP_LOGW(TAG, "Corrected MPS of EP 0x%02X from %u to 64", static_cast(ep->bEndpointAddress & 0xFF), + ep->wMaxPacketSize); ep_mutable->wMaxPacketSize = 64; } } @@ -314,7 +315,7 @@ void USBUartTypeCdcAcm::on_connected() { for (auto *channel : this->channels_) { if (i == cdc_devs.size()) { ESP_LOGE(TAG, "No configuration found for channel %d", channel->index_); - this->status_set_warning(LOG_STR("No configuration found for channel")); + this->status_set_warning("No configuration found for channel"); break; } channel->cdc_dev_ = cdc_devs[i++]; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index b41e0a52e9..a5e7905ac5 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/uart/uart_component.h" From 0d2d18c19806f2259ee73b1da7176a06afdbab82 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Sun, 28 Sep 2025 11:35:40 +0200 Subject: [PATCH 04/11] [usb_uart] Disable flow control on ch34x --- esphome/components/usb_uart/ch34x.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 7a0cc537fb..889366b579 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -72,6 +72,7 @@ void USBUartTypeCH34X::enable_channels() { if (channel->index_ >= 2) cmd += 0xE; this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); + this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); } USBUartTypeCdcAcm::enable_channels(); } From a7042687c1502a334b163b7fdf751832161e83d6 Mon Sep 17 00:00:00 2001 From: Brandon Ibach Date: Sun, 28 Sep 2025 06:58:42 -0400 Subject: [PATCH 05/11] [spi] fix SPI interface selection on ESP32-S2 and -S3 (#10732) (#10766) --- esphome/components/spi/__init__.py | 3 --- tests/components/spi/test.esp32-s3-ard.yaml | 13 +++++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 tests/components/spi/test.esp32-s3-ard.yaml diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 894c6d1878..d803ee66dc 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -276,9 +276,6 @@ def get_spi_interface(index): return ["&SPI", "&SPI1"][index] if index == 0: return "&SPI" - # Following code can't apply to C2, H2 or 8266 since they have only one SPI - if get_target_variant() in (VARIANT_ESP32S3, VARIANT_ESP32S2): - return "new SPIClass(FSPI)" return "new SPIClass(HSPI)" diff --git a/tests/components/spi/test.esp32-s3-ard.yaml b/tests/components/spi/test.esp32-s3-ard.yaml new file mode 100644 index 0000000000..e4d4f20586 --- /dev/null +++ b/tests/components/spi/test.esp32-s3-ard.yaml @@ -0,0 +1,13 @@ +spi: + - id: three_spi + interface: spi3 + clk_pin: + number: 47 + mosi_pin: + number: 40 + - id: hw_spi + interface: hardware + clk_pin: + number: 0 + miso_pin: + number: 41 From a2f833d66537d88f59df9f95ef700919744ef13d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 16:07:26 -0500 Subject: [PATCH 06/11] [captive_portal] Add DNS server support for ESP-IDF framework (#10919) --- esphome/components/captive_portal/__init__.py | 19 +- .../captive_portal/captive_portal.cpp | 60 +++-- .../captive_portal/captive_portal.h | 42 ++-- .../captive_portal/dns_server_esp32_idf.cpp | 205 ++++++++++++++++++ .../captive_portal/dns_server_esp32_idf.h | 27 +++ 5 files changed, 297 insertions(+), 56 deletions(-) create mode 100644 esphome/components/captive_portal/dns_server_esp32_idf.cpp create mode 100644 esphome/components/captive_portal/dns_server_esp32_idf.h diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 9f2af0a230..99acb76bcf 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -9,11 +10,19 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_LN882X, PLATFORM_RTL87XX, + PlatformFramework, ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority -AUTO_LOAD = ["web_server_base", "ota.web_server"] + +def AUTO_LOAD() -> list[str]: + auto_load = ["web_server_base", "ota.web_server"] + if CORE.using_esp_idf: + auto_load.append("socket") + return auto_load + + DEPENDENCIES = ["wifi"] CODEOWNERS = ["@esphome/core"] @@ -58,3 +67,11 @@ async def to_code(config): cg.add_library("DNSServer", None) if CORE.is_libretiny: cg.add_library("DNSServer", None) + + +# Only compile the ESP-IDF DNS server when using ESP-IDF framework +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "dns_server_esp32_idf.cpp": {PlatformFramework.ESP32_IDF}, + } +) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 7eb0ffa99e..20abc6506d 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -45,8 +45,8 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { request->send(stream); } void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { - std::string ssid = request->arg("ssid").c_str(); - std::string psk = request->arg("psk").c_str(); + std::string ssid = request->arg("ssid").c_str(); // NOLINT(readability-redundant-string-cstr) + std::string psk = request->arg("psk").c_str(); // NOLINT(readability-redundant-string-cstr) ESP_LOGI(TAG, "Requested WiFi Settings Change:"); ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); @@ -56,10 +56,8 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { } void CaptivePortal::setup() { -#ifndef USE_ARDUINO - // No DNS server needed for non-Arduino frameworks + // Disable loop by default - will be enabled when captive portal starts this->disable_loop(); -#endif } void CaptivePortal::start() { this->base_->init(); @@ -67,51 +65,47 @@ void CaptivePortal::start() { this->base_->add_handler(this); } + network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); + +#ifdef USE_ESP_IDF + // Create DNS server instance for ESP-IDF + this->dns_server_ = make_unique(); + this->dns_server_->start(ip); +#endif #ifdef USE_ARDUINO this->dns_server_ = make_unique(); this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); - network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); this->dns_server_->start(53, F("*"), ip); - // Re-enable loop() when DNS server is started - this->enable_loop(); #endif - this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *req) { - if (!this->active_ || req->host().c_str() == wifi::global_wifi_component->wifi_soft_ap_ip().str()) { - req->send(404, F("text/html"), F("File not found")); - return; - } - -#ifdef USE_ESP8266 - String url = F("http://"); - url += wifi::global_wifi_component->wifi_soft_ap_ip().str().c_str(); -#else - auto url = "http://" + wifi::global_wifi_component->wifi_soft_ap_ip().str(); -#endif - req->redirect(url.c_str()); - }); - this->initialized_ = true; this->active_ = true; + + // Enable loop() now that captive portal is active + this->enable_loop(); + + ESP_LOGV(TAG, "Captive portal started"); } void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { - if (req->url() == F("/")) { -#ifndef USE_ESP8266 - auto *response = req->beginResponse(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); -#else - auto *response = req->beginResponse_P(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); -#endif - response->addHeader(F("Content-Encoding"), F("gzip")); - req->send(response); - return; - } else if (req->url() == F("/config.json")) { + if (req->url() == F("/config.json")) { this->handle_config(req); return; } else if (req->url() == F("/wifisave")) { this->handle_wifisave(req); return; } + + // All other requests get the captive portal page + // This includes OS captive portal detection endpoints which will trigger + // the captive portal when they don't receive their expected responses +#ifndef USE_ESP8266 + auto *response = req->beginResponse(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); +#else + auto *response = req->beginResponse_P(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); +#endif + response->addHeader(F("Content-Encoding"), F("gzip")); + req->send(response); } CaptivePortal::CaptivePortal(web_server_base::WebServerBase *base) : base_(base) { global_captive_portal = this; } diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 382afe92f0..f48c286f0c 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -5,6 +5,9 @@ #ifdef USE_ARDUINO #include #endif +#ifdef USE_ESP_IDF +#include "dns_server_esp32_idf.h" +#endif #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" @@ -19,41 +22,36 @@ class CaptivePortal : public AsyncWebHandler, public Component { CaptivePortal(web_server_base::WebServerBase *base); void setup() override; void dump_config() override; -#ifdef USE_ARDUINO void loop() override { +#ifdef USE_ARDUINO if (this->dns_server_ != nullptr) { this->dns_server_->processNextRequest(); - } else { - this->disable_loop(); } - } #endif +#ifdef USE_ESP_IDF + if (this->dns_server_ != nullptr) { + this->dns_server_->process_next_request(); + } +#endif + } float get_setup_priority() const override; void start(); bool is_active() const { return this->active_; } void end() { this->active_ = false; + this->disable_loop(); // Stop processing DNS requests this->base_->deinit(); -#ifdef USE_ARDUINO - this->dns_server_->stop(); - this->dns_server_ = nullptr; -#endif + if (this->dns_server_ != nullptr) { + this->dns_server_->stop(); + this->dns_server_ = nullptr; + } } bool canHandle(AsyncWebServerRequest *request) const override { - if (!this->active_) - return false; - - if (request->method() == HTTP_GET) { - if (request->url() == F("/")) - return true; - if (request->url() == F("/config.json")) - return true; - if (request->url() == F("/wifisave")) - return true; - } - - return false; + // Handle all GET requests when captive portal is active + // This allows us to respond with the portal page for any URL, + // triggering OS captive portal detection + return this->active_ && request->method() == HTTP_GET; } void handle_config(AsyncWebServerRequest *request); @@ -66,7 +64,7 @@ class CaptivePortal : public AsyncWebHandler, public Component { web_server_base::WebServerBase *base_; bool initialized_{false}; bool active_{false}; -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) || defined(USE_ESP_IDF) std::unique_ptr dns_server_{nullptr}; #endif }; diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp new file mode 100644 index 0000000000..740107400a --- /dev/null +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -0,0 +1,205 @@ +#include "dns_server_esp32_idf.h" +#ifdef USE_ESP_IDF + +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/components/socket/socket.h" +#include +#include + +namespace esphome::captive_portal { + +static const char *const TAG = "captive_portal.dns"; + +// DNS constants +static constexpr uint16_t DNS_PORT = 53; +static constexpr uint16_t DNS_QR_FLAG = 1 << 15; +static constexpr uint16_t DNS_OPCODE_MASK = 0x7800; +static constexpr uint16_t DNS_QTYPE_A = 0x0001; +static constexpr uint16_t DNS_QCLASS_IN = 0x0001; +static constexpr uint16_t DNS_ANSWER_TTL = 300; + +// DNS Header structure +struct DNSHeader { + uint16_t id; + uint16_t flags; + uint16_t qd_count; + uint16_t an_count; + uint16_t ns_count; + uint16_t ar_count; +} __attribute__((packed)); + +// DNS Question structure +struct DNSQuestion { + uint16_t type; + uint16_t dns_class; +} __attribute__((packed)); + +// DNS Answer structure +struct DNSAnswer { + uint16_t ptr_offset; + uint16_t type; + uint16_t dns_class; + uint32_t ttl; + uint16_t addr_len; + uint32_t ip_addr; +} __attribute__((packed)); + +void DNSServer::start(const network::IPAddress &ip) { + this->server_ip_ = ip; + ESP_LOGV(TAG, "Starting DNS server on %s", ip.str().c_str()); + + // Create loop-monitored UDP socket + this->socket_ = socket::socket_ip_loop_monitored(SOCK_DGRAM, IPPROTO_UDP); + if (this->socket_ == nullptr) { + ESP_LOGE(TAG, "Socket create failed"); + return; + } + + // Set socket options + int enable = 1; + this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)); + + // Bind to port 53 + struct sockaddr_storage server_addr = {}; + socklen_t addr_len = socket::set_sockaddr_any((struct sockaddr *) &server_addr, sizeof(server_addr), DNS_PORT); + + int err = this->socket_->bind((struct sockaddr *) &server_addr, addr_len); + if (err != 0) { + ESP_LOGE(TAG, "Bind failed: %d", errno); + this->socket_ = nullptr; + return; + } + ESP_LOGV(TAG, "Bound to port %d", DNS_PORT); +} + +void DNSServer::stop() { + if (this->socket_ != nullptr) { + this->socket_->close(); + this->socket_ = nullptr; + } + ESP_LOGV(TAG, "Stopped"); +} + +void DNSServer::process_next_request() { + // Process one request if socket is valid and data is available + if (this->socket_ == nullptr || !this->socket_->ready()) { + return; + } + struct sockaddr_in client_addr; + socklen_t client_addr_len = sizeof(client_addr); + + // Receive DNS request using raw fd for recvfrom + int fd = this->socket_->get_fd(); + if (fd < 0) { + return; + } + + ssize_t len = recvfrom(fd, this->buffer_, sizeof(this->buffer_), MSG_DONTWAIT, (struct sockaddr *) &client_addr, + &client_addr_len); + + if (len < 0) { + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + ESP_LOGE(TAG, "recvfrom failed: %d", errno); + } + return; + } + + ESP_LOGVV(TAG, "Received %d bytes from %s:%d", len, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); + + if (len < static_cast(sizeof(DNSHeader) + 1)) { + ESP_LOGV(TAG, "Request too short: %d", len); + return; + } + + // Parse DNS header + DNSHeader *header = (DNSHeader *) this->buffer_; + uint16_t flags = ntohs(header->flags); + uint16_t qd_count = ntohs(header->qd_count); + + // Check if it's a standard query + if ((flags & DNS_QR_FLAG) || (flags & DNS_OPCODE_MASK) || qd_count != 1) { + ESP_LOGV(TAG, "Not a standard query: flags=0x%04X, qd_count=%d", flags, qd_count); + return; // Not a standard query + } + + // Parse domain name (we don't actually care about it - redirect everything) + uint8_t *ptr = this->buffer_ + sizeof(DNSHeader); + uint8_t *end = this->buffer_ + len; + + while (ptr < end && *ptr != 0) { + uint8_t label_len = *ptr; + if (label_len > 63) { // Check for invalid label length + return; + } + // Check if we have room for this label plus the length byte + if (ptr + label_len + 1 > end) { + return; // Would overflow + } + ptr += label_len + 1; + } + + // Check if we reached a proper null terminator + if (ptr >= end || *ptr != 0) { + return; // Name not terminated or truncated + } + ptr++; // Skip the null terminator + + // Check we have room for the question + if (ptr + sizeof(DNSQuestion) > end) { + return; // Request truncated + } + + // Parse DNS question + DNSQuestion *question = (DNSQuestion *) ptr; + uint16_t qtype = ntohs(question->type); + uint16_t qclass = ntohs(question->dns_class); + + // We only handle A queries + if (qtype != DNS_QTYPE_A || qclass != DNS_QCLASS_IN) { + ESP_LOGV(TAG, "Not an A query: type=0x%04X, class=0x%04X", qtype, qclass); + return; // Not an A query + } + + // Build DNS response by modifying the request in-place + header->flags = htons(DNS_QR_FLAG | 0x8000); // Response + Authoritative + header->an_count = htons(1); // One answer + + // Add answer section after the question + size_t question_len = (ptr + sizeof(DNSQuestion)) - this->buffer_ - sizeof(DNSHeader); + size_t answer_offset = sizeof(DNSHeader) + question_len; + + // Check if we have room for the answer + if (answer_offset + sizeof(DNSAnswer) > sizeof(this->buffer_)) { + ESP_LOGW(TAG, "Response too large"); + return; + } + + DNSAnswer *answer = (DNSAnswer *) (this->buffer_ + answer_offset); + + // Pointer to name in question (offset from start of packet) + answer->ptr_offset = htons(0xC000 | sizeof(DNSHeader)); + answer->type = htons(DNS_QTYPE_A); + answer->dns_class = htons(DNS_QCLASS_IN); + answer->ttl = htonl(DNS_ANSWER_TTL); + answer->addr_len = htons(4); + + // Get the raw IP address + ip4_addr_t addr = this->server_ip_; + answer->ip_addr = addr.addr; + + size_t response_len = answer_offset + sizeof(DNSAnswer); + + // Send response + ssize_t sent = + this->socket_->sendto(this->buffer_, response_len, 0, (struct sockaddr *) &client_addr, client_addr_len); + if (sent < 0) { + ESP_LOGV(TAG, "Send failed: %d", errno); + } else { + ESP_LOGV(TAG, "Sent %d bytes", sent); + } +} + +} // namespace esphome::captive_portal + +#endif // USE_ESP_IDF diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/captive_portal/dns_server_esp32_idf.h new file mode 100644 index 0000000000..13d9def8e3 --- /dev/null +++ b/esphome/components/captive_portal/dns_server_esp32_idf.h @@ -0,0 +1,27 @@ +#pragma once +#ifdef USE_ESP_IDF + +#include +#include "esphome/core/helpers.h" +#include "esphome/components/network/ip_address.h" +#include "esphome/components/socket/socket.h" + +namespace esphome::captive_portal { + +class DNSServer { + public: + void start(const network::IPAddress &ip); + void stop(); + void process_next_request(); + + protected: + static constexpr size_t DNS_BUFFER_SIZE = 192; + + std::unique_ptr socket_{nullptr}; + network::IPAddress server_ip_; + uint8_t buffer_[DNS_BUFFER_SIZE]; +}; + +} // namespace esphome::captive_portal + +#endif // USE_ESP_IDF From 34b4cb46f606d2e9cb0c817f984e9fa6cee1462f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 16:33:36 -0500 Subject: [PATCH 07/11] [esp32_improv] Make device name visible to passive BLE scanners (#10918) --- esphome/components/esp32_ble/ble.cpp | 22 +++ esphome/components/esp32_ble/ble.h | 2 + .../components/esp32_ble/ble_advertising.cpp | 8 +- .../components/esp32_ble/ble_advertising.h | 4 + .../esp32_improv/esp32_improv_component.cpp | 132 +++++++++++++----- .../esp32_improv/esp32_improv_component.h | 7 +- 6 files changed, 136 insertions(+), 39 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6b6b19e079..64cef70de2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -73,6 +73,28 @@ void ESP32BLE::advertising_set_manufacturer_data(const std::vector &dat this->advertising_start(); } +void ESP32BLE::advertising_set_service_data_and_name(std::span data, bool include_name) { + // This method atomically updates both service data and device name inclusion in BLE advertising. + // When include_name is true, the device name is included in the advertising packet making it + // visible to passive BLE scanners. When false, the name is only visible in scan response + // (requires active scanning). This atomic operation ensures we only restart advertising once + // when changing both properties, avoiding the brief gap that would occur with separate calls. + + this->advertising_init_(); + + if (include_name) { + // When including name, clear service data first to avoid packet overflow + this->advertising_->set_service_data(std::span{}); + this->advertising_->set_include_name(true); + } else { + // When including service data, clear name first to avoid packet overflow + this->advertising_->set_include_name(false); + this->advertising_->set_service_data(data); + } + + this->advertising_start(); +} + void ESP32BLE::advertising_register_raw_advertisement_callback(std::function &&callback) { this->advertising_init_(); this->advertising_->register_raw_advertisement_callback(std::move(callback)); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 368ac644cf..1aa3bc86ef 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -9,6 +9,7 @@ #endif #include +#include #include "esphome/core/automation.h" #include "esphome/core/component.h" @@ -118,6 +119,7 @@ class ESP32BLE : public Component { void advertising_set_service_data(const std::vector &data); void advertising_set_manufacturer_data(const std::vector &data); void advertising_set_appearance(uint16_t appearance) { this->appearance_ = appearance; } + void advertising_set_service_data_and_name(std::span data, bool include_name); void advertising_add_service_uuid(ESPBTUUID uuid); void advertising_remove_service_uuid(ESPBTUUID uuid); void advertising_register_raw_advertisement_callback(std::function &&callback); diff --git a/esphome/components/esp32_ble/ble_advertising.cpp b/esphome/components/esp32_ble/ble_advertising.cpp index d8b9b1cc36..df70768c23 100644 --- a/esphome/components/esp32_ble/ble_advertising.cpp +++ b/esphome/components/esp32_ble/ble_advertising.cpp @@ -43,7 +43,7 @@ void BLEAdvertising::remove_service_uuid(ESPBTUUID uuid) { this->advertising_uuids_.end()); } -void BLEAdvertising::set_service_data(const std::vector &data) { +void BLEAdvertising::set_service_data(std::span data) { delete[] this->advertising_data_.p_service_data; this->advertising_data_.p_service_data = nullptr; this->advertising_data_.service_data_len = data.size(); @@ -54,6 +54,10 @@ void BLEAdvertising::set_service_data(const std::vector &data) { } } +void BLEAdvertising::set_service_data(const std::vector &data) { + this->set_service_data(std::span(data)); +} + void BLEAdvertising::set_manufacturer_data(const std::vector &data) { delete[] this->advertising_data_.p_manufacturer_data; this->advertising_data_.p_manufacturer_data = nullptr; @@ -84,7 +88,7 @@ esp_err_t BLEAdvertising::services_advertisement_() { esp_err_t err; this->advertising_data_.set_scan_rsp = false; - this->advertising_data_.include_name = !this->scan_response_; + this->advertising_data_.include_name = this->include_name_in_adv_ || !this->scan_response_; this->advertising_data_.include_txpower = !this->scan_response_; err = esp_ble_gap_config_adv_data(&this->advertising_data_); if (err != ESP_OK) { diff --git a/esphome/components/esp32_ble/ble_advertising.h b/esphome/components/esp32_ble/ble_advertising.h index e373554ea9..7a31d926f6 100644 --- a/esphome/components/esp32_ble/ble_advertising.h +++ b/esphome/components/esp32_ble/ble_advertising.h @@ -4,6 +4,7 @@ #include #include +#include #include #ifdef USE_ESP32 @@ -36,6 +37,8 @@ class BLEAdvertising { void set_manufacturer_data(const std::vector &data); void set_appearance(uint16_t appearance) { this->advertising_data_.appearance = appearance; } void set_service_data(const std::vector &data); + void set_service_data(std::span data); + void set_include_name(bool include_name) { this->include_name_in_adv_ = include_name; } void register_raw_advertisement_callback(std::function &&callback); void start(); @@ -45,6 +48,7 @@ class BLEAdvertising { esp_err_t services_advertisement_(); bool scan_response_; + bool include_name_in_adv_{false}; esp_ble_adv_data_t advertising_data_; esp_ble_adv_data_t scan_response_data_; esp_ble_adv_params_t advertising_params_; diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index c5a0b89f99..ca08ff0cca 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -17,6 +17,13 @@ static const char *const TAG = "esp32_improv.component"; static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; static constexpr uint16_t STOP_ADVERTISING_DELAY = 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state +static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds +static constexpr uint16_t NAME_ADVERTISING_DURATION = 1000; // Advertise name for 1 second + +// Improv service data constants +static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8; +static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7 +static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7 ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } @@ -99,6 +106,11 @@ void ESP32ImprovComponent::loop() { this->process_incoming_data_(); uint32_t now = App.get_loop_component_start_time(); + // Check if we need to update advertising type + if (this->state_ != improv::STATE_STOPPED && this->state_ != improv::STATE_PROVISIONED) { + this->update_advertising_type_(); + } + switch (this->state_) { case improv::STATE_STOPPED: this->set_status_indicator_state_(false); @@ -107,9 +119,15 @@ void ESP32ImprovComponent::loop() { if (this->service_->is_created()) { this->service_->start(); } else if (this->service_->is_running()) { + // Start by advertising the device name first BEFORE setting any state + ESP_LOGV(TAG, "Starting with device name advertising"); + this->advertising_device_name_ = true; + this->last_name_adv_time_ = App.get_loop_component_start_time(); + esp32_ble::global_ble->advertising_set_service_data_and_name(std::span{}, true); esp32_ble::global_ble->advertising_start(); - this->set_state_(improv::STATE_AWAITING_AUTHORIZATION); + // Set initial state based on whether we have an authorizer + this->set_state_(this->get_initial_state_(), false); this->set_error_(improv::ERROR_NONE); ESP_LOGD(TAG, "Service started!"); } @@ -120,24 +138,21 @@ void ESP32ImprovComponent::loop() { if (this->authorizer_ == nullptr || (this->authorized_start_ != 0 && ((now - this->authorized_start_) < this->authorized_duration_))) { this->set_state_(improv::STATE_AUTHORIZED); - } else -#else - { this->set_state_(improv::STATE_AUTHORIZED); } -#endif - { + } else { if (!this->check_identify_()) this->set_status_indicator_state_(true); } +#else + this->set_state_(improv::STATE_AUTHORIZED); +#endif break; } case improv::STATE_AUTHORIZED: { #ifdef USE_BINARY_SENSOR - if (this->authorizer_ != nullptr) { - if (now - this->authorized_start_ > this->authorized_duration_) { - ESP_LOGD(TAG, "Authorization timeout"); - this->set_state_(improv::STATE_AWAITING_AUTHORIZATION); - return; - } + if (this->authorizer_ != nullptr && now - this->authorized_start_ > this->authorized_duration_) { + ESP_LOGD(TAG, "Authorization timeout"); + this->set_state_(improv::STATE_AWAITING_AUTHORIZATION); + return; } #endif if (!this->check_identify_()) { @@ -226,12 +241,15 @@ bool ESP32ImprovComponent::check_identify_() { return identify; } -void ESP32ImprovComponent::set_state_(improv::State state) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG - if (this->state_ != state) { - ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, - this->state_to_string_(state), state); +void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) { + // Skip if state hasn't changed + if (this->state_ == state) { + return; } + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, + this->state_to_string_(state), state); #endif this->state_ = state; if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) { @@ -243,25 +261,13 @@ void ESP32ImprovComponent::set_state_(improv::State state) { // STATE_STOPPED (0x00) is internal only and not part of the Improv spec. // Advertising 0x00 causes undefined behavior in some clients and makes them // repeatedly connect trying to determine the actual state. - if (state != improv::STATE_STOPPED) { - std::vector service_data(8, 0); - service_data[0] = 0x77; // PR - service_data[1] = 0x46; // IM - service_data[2] = static_cast(state); - - uint8_t capabilities = 0x00; -#ifdef USE_OUTPUT - if (this->status_indicator_ != nullptr) - capabilities |= improv::CAPABILITY_IDENTIFY; -#endif - - service_data[3] = capabilities; - service_data[4] = 0x00; // Reserved - service_data[5] = 0x00; // Reserved - service_data[6] = 0x00; // Reserved - service_data[7] = 0x00; // Reserved - - esp32_ble::global_ble->advertising_set_service_data(service_data); + if (state != improv::STATE_STOPPED && update_advertising) { + // State change always overrides name advertising and resets the timer + this->advertising_device_name_ = false; + // Reset the timer so we wait another 60 seconds before advertising name + this->last_name_adv_time_ = App.get_loop_component_start_time(); + // Advertise the new state via service data + this->advertise_service_data_(); } #ifdef USE_ESP32_IMPROV_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); @@ -388,6 +394,60 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { wifi::global_wifi_component->clear_sta(); } +void ESP32ImprovComponent::advertise_service_data_() { + uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {}; + service_data[0] = IMPROV_PROTOCOL_ID_1; // PR + service_data[1] = IMPROV_PROTOCOL_ID_2; // IM + service_data[2] = static_cast(this->state_); + + uint8_t capabilities = 0x00; +#ifdef USE_OUTPUT + if (this->status_indicator_ != nullptr) + capabilities |= improv::CAPABILITY_IDENTIFY; +#endif + + service_data[3] = capabilities; + // service_data[4-7] are already 0 (Reserved) + + // Atomically set service data and disable name in advertising + esp32_ble::global_ble->advertising_set_service_data_and_name(std::span(service_data), false); +} + +void ESP32ImprovComponent::update_advertising_type_() { + uint32_t now = App.get_loop_component_start_time(); + + // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data + if (this->advertising_device_name_) { + if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_DURATION) { + ESP_LOGV(TAG, "Switching back to service data advertising"); + this->advertising_device_name_ = false; + // Restore service data advertising + this->advertise_service_data_(); + } + return; + } + + // Check if it's time to advertise the device name (every NAME_ADVERTISING_INTERVAL) + if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_INTERVAL) { + ESP_LOGV(TAG, "Switching to device name advertising"); + this->advertising_device_name_ = true; + this->last_name_adv_time_ = now; + + // Atomically clear service data and enable name in advertising data + esp32_ble::global_ble->advertising_set_service_data_and_name(std::span{}, true); + } +} + +improv::State ESP32ImprovComponent::get_initial_state_() const { +#ifdef USE_BINARY_SENSOR + // If we have an authorizer, start in awaiting authorization state + return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION; +#else + // No binary_sensor support = no authorizer possible, start as authorized + return improv::STATE_AUTHORIZED; +#endif +} + ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esp32_improv diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 686da08111..eb07e09dce 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -100,14 +100,19 @@ class ESP32ImprovComponent : public Component { #endif bool status_indicator_state_{false}; + uint32_t last_name_adv_time_{0}; + bool advertising_device_name_{false}; void set_status_indicator_state_(bool state); + void update_advertising_type_(); - void set_state_(improv::State state); + void set_state_(improv::State state, bool update_advertising = true); void set_error_(improv::Error error); + improv::State get_initial_state_() const; void send_response_(std::vector &response); void process_incoming_data_(); void on_wifi_connect_timeout_(); bool check_identify_(); + void advertise_service_data_(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG const char *state_to_string_(improv::State state); #endif From 0111f725ffdf02afecb0ea02c7a5d6d910cd1da4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 16:34:32 -0500 Subject: [PATCH 08/11] [esp32_ble_tracker] Reduce gap_scan_result log verbosity to VV (#10917) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 908bb36c9e..a7d73a9709 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -295,7 +295,7 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { // Note: This handler is called from the main loop context via esp32_ble's event queue. // We process advertisements immediately instead of buffering them. - ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); + ESP_LOGVV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { // Process the scan result immediately From ef73ae21164748d5253be838a786420b5136e97f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 16:36:40 -0500 Subject: [PATCH 09/11] [esp32_ble_server] Replace HashMap with vector for services - saves 1KB flash, 26x faster (#10894) --- .../esp32_ble_server/ble_server.cpp | 44 +++++++++---------- .../components/esp32_ble_server/ble_server.h | 9 +++- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 5339bf8aed..89299bb417 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -70,11 +70,11 @@ void BLEServer::loop() { // it is at the top of the GATT table this->device_information_service_->do_create(this); // Create all services previously created - for (auto &pair : this->services_) { - if (pair.second == this->device_information_service_) { + for (auto &entry : this->services_) { + if (entry.service == this->device_information_service_) { continue; } - pair.second->do_create(this); + entry.service->do_create(this); } this->state_ = STARTING_SERVICE; } @@ -118,7 +118,7 @@ BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t n } BLEService *service = // NOLINT(cppcoreguidelines-owning-memory) new BLEService(uuid, num_handles, inst_id, advertise); - this->services_.emplace(BLEServer::get_service_key(uuid, inst_id), service); + this->services_.push_back({uuid, inst_id, service}); if (this->parent_->is_active() && this->registered_) { service->do_create(this); } @@ -127,26 +127,24 @@ BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t n void BLEServer::remove_service(ESPBTUUID uuid, uint8_t inst_id) { ESP_LOGV(TAG, "Removing BLE service - %s %d", uuid.to_string().c_str(), inst_id); - BLEService *service = this->get_service(uuid, inst_id); - if (service == nullptr) { - ESP_LOGW(TAG, "BLE service %s %d does not exist", uuid.to_string().c_str(), inst_id); - return; + for (auto it = this->services_.begin(); it != this->services_.end(); ++it) { + if (it->uuid == uuid && it->inst_id == inst_id) { + it->service->do_delete(); + delete it->service; // NOLINT(cppcoreguidelines-owning-memory) + this->services_.erase(it); + return; + } } - service->do_delete(); - delete service; // NOLINT(cppcoreguidelines-owning-memory) - this->services_.erase(BLEServer::get_service_key(uuid, inst_id)); + ESP_LOGW(TAG, "BLE service %s %d does not exist", uuid.to_string().c_str(), inst_id); } BLEService *BLEServer::get_service(ESPBTUUID uuid, uint8_t inst_id) { - BLEService *service = nullptr; - if (this->services_.count(BLEServer::get_service_key(uuid, inst_id)) > 0) { - service = this->services_.at(BLEServer::get_service_key(uuid, inst_id)); + for (auto &entry : this->services_) { + if (entry.uuid == uuid && entry.inst_id == inst_id) { + return entry.service; + } } - return service; -} - -std::string BLEServer::get_service_key(ESPBTUUID uuid, uint8_t inst_id) { - return uuid.to_string() + std::to_string(inst_id); + return nullptr; } void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, @@ -174,8 +172,8 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga break; } - for (const auto &pair : this->services_) { - pair.second->gatts_event_handler(event, gatts_if, param); + for (auto &entry : this->services_) { + entry.service->gatts_event_handler(event, gatts_if, param); } } @@ -183,8 +181,8 @@ void BLEServer::ble_before_disabled_event_handler() { // Delete all clients this->clients_.clear(); // Delete all services - for (auto &pair : this->services_) { - pair.second->do_delete(); + for (auto &entry : this->services_) { + entry.service->do_delete(); } this->registered_ = false; this->state_ = INIT; diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 531b52d6b9..b5973ed099 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -66,7 +66,12 @@ class BLEServer : public Component, void ble_before_disabled_event_handler() override; protected: - static std::string get_service_key(ESPBTUUID uuid, uint8_t inst_id); + struct ServiceEntry { + ESPBTUUID uuid; + uint8_t inst_id; + BLEService *service; + }; + void restart_advertising_(); void add_client_(uint16_t conn_id) { this->clients_.insert(conn_id); } @@ -77,7 +82,7 @@ class BLEServer : public Component, bool registered_{false}; std::unordered_set clients_; - std::unordered_map services_{}; + std::vector services_{}; std::vector services_to_start_{}; BLEService *device_information_service_{}; From ab79e596b53e47b938df6d3c4de75eff99d262c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 17:32:16 -0500 Subject: [PATCH 10/11] [esp32_ble_server] Optimize notification and action managers for typical use cases (#10897) --- .../esp32_ble_server/ble_characteristic.cpp | 37 +++++++++++++---- .../esp32_ble_server/ble_characteristic.h | 11 ++++- .../ble_server_automations.cpp | 41 ++++++++++++++----- .../esp32_ble_server/ble_server_automations.h | 21 ++++++++-- 4 files changed, 88 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 373d57436e..fabcc75321 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -51,11 +51,11 @@ void BLECharacteristic::notify() { for (auto &client : this->service_->get_server()->get_clients()) { size_t length = this->value_.size(); - // If the client is not in the list of clients to notify, skip it - if (this->clients_to_notify_.count(client) == 0) + // Find the client in the list of clients to notify + auto *entry = this->find_client_in_notify_list_(client); + if (entry == nullptr) continue; - // If the client is in the list of clients to notify, check if it requires an ack (i.e. INDICATE) - bool require_ack = this->clients_to_notify_[client]; + bool require_ack = entry->indicate; // TODO: Remove this block when INDICATE acknowledgment is supported if (require_ack) { ESP_LOGW(TAG, "INDICATE acknowledgment is not yet supported (i.e. it works as a NOTIFY)"); @@ -79,10 +79,11 @@ void BLECharacteristic::add_descriptor(BLEDescriptor *descriptor) { uint16_t cccd = encode_uint16(value[1], value[0]); bool notify = (cccd & 1) != 0; bool indicate = (cccd & 2) != 0; + // Remove existing entry if present + this->remove_client_from_notify_list_(conn_id); + // Add new entry if needed if (notify || indicate) { - this->clients_to_notify_[conn_id] = indicate; - } else { - this->clients_to_notify_.erase(conn_id); + this->clients_to_notify_.push_back({conn_id, indicate}); } }); } @@ -307,6 +308,28 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt } } +void BLECharacteristic::remove_client_from_notify_list_(uint16_t conn_id) { + // Since we typically have very few clients (often just 1), we can optimize + // for the common case by swapping with the last element and popping + for (size_t i = 0; i < this->clients_to_notify_.size(); i++) { + if (this->clients_to_notify_[i].conn_id == conn_id) { + // Swap with last element and pop (safe even when i is the last element) + this->clients_to_notify_[i] = this->clients_to_notify_.back(); + this->clients_to_notify_.pop_back(); + return; + } + } +} + +BLECharacteristic::ClientNotificationEntry *BLECharacteristic::find_client_in_notify_list_(uint16_t conn_id) { + for (auto &entry : this->clients_to_notify_) { + if (entry.conn_id == conn_id) { + return &entry; + } + } + return nullptr; +} + } // namespace esp32_ble_server } // namespace esphome diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 3698b8c4aa..97b3af2a21 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -6,7 +6,6 @@ #include "esphome/components/bytebuffer/bytebuffer.h" #include -#include #ifdef USE_ESP32 @@ -89,7 +88,15 @@ class BLECharacteristic : public EventEmitter descriptors_; - std::unordered_map clients_to_notify_; + + struct ClientNotificationEntry { + uint16_t conn_id; + bool indicate; // true = indicate, false = notify + }; + std::vector clients_to_notify_; + + void remove_client_from_notify_list_(uint16_t conn_id); + ClientNotificationEntry *find_client_in_notify_list_(uint16_t conn_id); esp_gatt_perm_t permissions_ = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.cpp b/esphome/components/esp32_ble_server/ble_server_automations.cpp index 41ef2b8bfe..b140e08b46 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.cpp +++ b/esphome/components/esp32_ble_server/ble_server_automations.cpp @@ -45,17 +45,16 @@ Trigger *BLETriggers::create_server_on_disconnect_trigger(BLEServer *s void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *characteristic, EventEmitterListenerID listener_id, const std::function &pre_notify_listener) { - // Check if there is already a listener for this characteristic - if (this->listeners_.count(characteristic) > 0) { - // Unpack the pair listener_id, pre_notify_listener_id - auto listener_pairs = this->listeners_[characteristic]; - EventEmitterListenerID old_listener_id = listener_pairs.first; - EventEmitterListenerID old_pre_notify_listener_id = listener_pairs.second; + // Find and remove existing listener for this characteristic + auto *existing = this->find_listener_(characteristic); + if (existing != nullptr) { // Remove the previous listener characteristic->EventEmitter::off(BLECharacteristicEvt::EmptyEvt::ON_READ, - old_listener_id); + existing->listener_id); // Remove the pre-notify listener - this->off(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, old_pre_notify_listener_id); + this->off(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, existing->pre_notify_listener_id); + // Remove from vector + this->remove_listener_(characteristic); } // Create a new listener for the pre-notify event EventEmitterListenerID pre_notify_listener_id = @@ -66,8 +65,30 @@ void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *cha pre_notify_listener(); } }); - // Save the pair listener_id, pre_notify_listener_id to the map - this->listeners_[characteristic] = std::make_pair(listener_id, pre_notify_listener_id); + // Save the entry to the vector + this->listeners_.push_back({characteristic, listener_id, pre_notify_listener_id}); +} + +BLECharacteristicSetValueActionManager::ListenerEntry *BLECharacteristicSetValueActionManager::find_listener_( + BLECharacteristic *characteristic) { + for (auto &entry : this->listeners_) { + if (entry.characteristic == characteristic) { + return &entry; + } + } + return nullptr; +} + +void BLECharacteristicSetValueActionManager::remove_listener_(BLECharacteristic *characteristic) { + // Since we typically have very few listeners, optimize by swapping with back and popping + for (size_t i = 0; i < this->listeners_.size(); i++) { + if (this->listeners_[i].characteristic == characteristic) { + // Swap with last element and pop (safe even when i is the last element) + this->listeners_[i] = this->listeners_.back(); + this->listeners_.pop_back(); + return; + } + } } } // namespace esp32_ble_server_automations diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index eab6b05f05..910335826c 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -8,7 +8,6 @@ #include "esphome/core/automation.h" #include -#include #include #ifdef USE_ESP32 @@ -21,6 +20,9 @@ namespace esp32_ble_server_automations { using namespace esp32_ble; using namespace event_emitter; +// Invalid listener ID constant - 0 is used as sentinel value in EventEmitter +static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; + class BLETriggers { public: static Trigger, uint16_t> *create_characteristic_on_write_trigger( @@ -46,14 +48,27 @@ class BLECharacteristicSetValueActionManager void set_listener(BLECharacteristic *characteristic, EventEmitterListenerID listener_id, const std::function &pre_notify_listener); EventEmitterListenerID get_listener(BLECharacteristic *characteristic) { - return this->listeners_[characteristic].first; + for (const auto &entry : this->listeners_) { + if (entry.characteristic == characteristic) { + return entry.listener_id; + } + } + return INVALID_LISTENER_ID; } void emit_pre_notify(BLECharacteristic *characteristic) { this->emit_(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, characteristic); } private: - std::unordered_map> listeners_; + struct ListenerEntry { + BLECharacteristic *characteristic; + EventEmitterListenerID listener_id; + EventEmitterListenerID pre_notify_listener_id; + }; + std::vector listeners_; + + ListenerEntry *find_listener_(BLECharacteristic *characteristic); + void remove_listener_(BLECharacteristic *characteristic); }; template class BLECharacteristicSetValueAction : public Action { From e07af13bef28dcc37e1ffbd464b519c4d28fbd6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 18:25:29 -0500 Subject: [PATCH 11/11] [usb_host] Fix double-free crash with lock-free atomic pool allocation --- esphome/components/usb_host/usb_host.h | 43 +++++++--- .../components/usb_host/usb_host_client.cpp | 80 ++++++++++++++++--- esphome/components/usb_uart/usb_uart.cpp | 13 ++- 3 files changed, 109 insertions(+), 27 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 8cf313aa9b..e1e96cfce3 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -9,11 +9,31 @@ #include #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" -#include +#include namespace esphome { namespace usb_host { +// THREADING MODEL: +// This component uses a dedicated USB task for event processing to prevent data loss. +// - USB Task (high priority): Handles USB events, executes transfer callbacks +// - Main Loop Task: Initiates transfers, processes completion events +// +// Thread-safe communication: +// - Lock-free queues for USB task -> main loop events (SPSC pattern) +// - Lock-free TransferRequest pool using atomic bitmask (MCSP pattern) +// +// TransferRequest pool access pattern: +// - get_trq_() [allocate]: Called from BOTH USB task and main loop threads +// * USB task: via USB UART input callbacks that restart transfers immediately +// * Main loop: for output transfers and flow-controlled input restarts +// - release_trq() [deallocate]: Called from main loop thread only +// +// The multi-threaded allocation is intentional for performance: +// - USB task can immediately restart input transfers without context switching +// - Main loop controls backpressure by deciding when to restart after consuming data +// The atomic bitmask ensures thread-safe allocation without mutex blocking. + static const char *const TAG = "usb_host"; // Forward declarations @@ -98,13 +118,7 @@ class USBClient : public Component { friend class USBHost; public: - USBClient(uint16_t vid, uint16_t pid) : vid_(vid), pid_(pid) { init_pool(); } - - void init_pool() { - this->trq_pool_.clear(); - for (size_t i = 0; i != MAX_REQUESTS; i++) - this->trq_pool_.push_back(&this->requests_[i]); - } + USBClient(uint16_t vid, uint16_t pid) : vid_(vid), pid_(pid), trq_in_use_(0) {} void setup() override; void loop() override; // setup must happen after the host bus has been setup @@ -126,10 +140,13 @@ class USBClient : public Component { protected: bool register_(); - TransferRequest *get_trq_(); + TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe) virtual void disconnect(); virtual void on_connected() {} - virtual void on_disconnected() { this->init_pool(); } + virtual void on_disconnected() { + // Reset all requests to available (all bits to 0) + this->trq_in_use_.store(0); + } // USB task management static void usb_task_fn(void *arg); @@ -143,7 +160,11 @@ class USBClient : public Component { int state_{USB_CLIENT_INIT}; uint16_t vid_{}; uint16_t pid_{}; - std::list trq_pool_{}; + // Lock-free pool management using atomic bitmask (no dynamic allocation) + // Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available + // Supports multiple concurrent consumers (both threads can allocate) + // Single producer for deallocation (main loop only) + std::atomic trq_in_use_; TransferRequest requests_[MAX_REQUESTS]{}; }; class USBHost : public Component { diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index a9d4d42a8c..1cfe2f5f13 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace esphome { namespace usb_host { @@ -185,9 +186,11 @@ void USBClient::setup() { this->mark_failed(); return; } - for (auto *trq : this->trq_pool_) { - usb_host_transfer_alloc(64, 0, &trq->transfer); - trq->client = this; + // Pre-allocate USB transfer buffers for all slots at startup + // This avoids any dynamic allocation during runtime + for (size_t i = 0; i < MAX_REQUESTS; i++) { + usb_host_transfer_alloc(64, 0, &this->requests_[i].transfer); + this->requests_[i].client = this; // Set once, never changes } // Create and start USB task @@ -347,17 +350,39 @@ static void control_callback(const usb_transfer_t *xfer) { queue_transfer_cleanup(trq, EVENT_CONTROL_COMPLETE); } +// THREAD CONTEXT: Called from both USB task and main loop threads (multi-consumer) +// - USB task: USB UART input callbacks restart transfers for immediate data reception +// - Main loop: Output transfers and flow-controlled input restarts after consuming data +// +// THREAD SAFETY: Lock-free using atomic compare-and-swap on bitmask +// This multi-threaded access is intentional for performance - USB task can +// immediately restart transfers without waiting for main loop scheduling. TransferRequest *USBClient::get_trq_() { - if (this->trq_pool_.empty()) { - ESP_LOGE(TAG, "Too many requests queued"); - return nullptr; + uint16_t mask = this->trq_in_use_.load(std::memory_order_relaxed); + + // Find first available slot (bit = 0) and try to claim it atomically + for (size_t i = 0; i < MAX_REQUESTS; i++) { + if (!(mask & (1U << i))) { + // Slot i appears available, try to claim it atomically + uint16_t expected = mask; + uint16_t desired = mask | (1U << i); // Set bit i to mark as in-use + + if (this->trq_in_use_.compare_exchange_weak(expected, desired, std::memory_order_acquire, + std::memory_order_relaxed)) { + // Successfully claimed slot i - prepare the TransferRequest + auto *trq = &this->requests_[i]; + trq->transfer->context = trq; + trq->transfer->device_handle = this->device_handle_; + return trq; + } + // Another thread claimed this slot, retry with updated mask + mask = expected; + i--; // Retry the same index with new mask value + } } - auto *trq = this->trq_pool_.front(); - this->trq_pool_.pop_front(); - trq->client = this; - trq->transfer->context = trq; - trq->transfer->device_handle = this->device_handle_; - return trq; + + ESP_LOGE(TAG, "Too many requests queued (all %d slots in use)", MAX_REQUESTS); + return nullptr; } void USBClient::disconnect() { this->on_disconnected(); @@ -370,6 +395,8 @@ void USBClient::disconnect() { this->device_addr_ = -1; } +// THREAD CONTEXT: Called from main loop thread only +// - Used for device configuration and control operations bool USBClient::control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, const std::vector &data) { auto *trq = this->get_trq_(); @@ -425,6 +452,9 @@ static void transfer_callback(usb_transfer_t *xfer) { } /** * Performs a transfer input operation. + * THREAD CONTEXT: Called from both USB task and main loop threads! + * - USB task: USB UART input callbacks call start_input() which calls this + * - Main loop: Initial setup and other components * * @param ep_address The endpoint address. * @param callback The callback function to be called when the transfer is complete. @@ -451,6 +481,9 @@ void USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, u /** * Performs an output transfer operation. + * THREAD CONTEXT: Called from main loop thread only + * - USB UART output uses defer() to ensure main loop context + * - Modbus and other components call from loop() * * @param ep_address The endpoint address. * @param callback The callback function to be called when the transfer is complete. @@ -483,7 +516,28 @@ void USBClient::dump_config() { " Product id %04X", this->vid_, this->pid_); } -void USBClient::release_trq(TransferRequest *trq) { this->trq_pool_.push_back(trq); } +// THREAD CONTEXT: Only called from main loop thread (single producer for deallocation) +// - Via event processing when handling EVENT_TRANSFER_COMPLETE/EVENT_CONTROL_COMPLETE +// - Directly when transfer submission fails +// +// THREAD SAFETY: Lock-free using atomic AND to clear bit +// Single-producer pattern makes this simpler than allocation +void USBClient::release_trq(TransferRequest *trq) { + if (trq == nullptr) + return; + + // Calculate index from pointer arithmetic + size_t index = trq - this->requests_; + if (index >= MAX_REQUESTS) { + ESP_LOGE(TAG, "Invalid TransferRequest pointer"); + return; + } + + // Atomically clear bit i to mark slot as available + // fetch_and with inverted bitmask clears the bit atomically + uint16_t bit = 1U << index; + this->trq_in_use_.fetch_and(~bit, std::memory_order_release); +} } // namespace usb_host } // namespace esphome diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index a8a8bc231c..29003e071e 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -216,9 +216,16 @@ void USBUartComponent::dump_config() { void USBUartComponent::start_input(USBUartChannel *channel) { if (!channel->initialised_.load() || channel->input_started_.load()) return; - // Note: This function is called from both USB task and main loop, so we cannot - // directly check ring buffer space here. Backpressure is handled by the chunk pool: - // when exhausted, USB input stops until chunks are freed by the main loop + // THREAD CONTEXT: Called from both USB task and main loop threads + // - USB task: Immediate restart after successful transfer for continuous data flow + // - Main loop: Controlled restart after consuming data (backpressure mechanism) + // + // This dual-thread access is intentional for performance: + // - USB task restarts avoid context switch delays for high-speed data + // - Main loop restarts provide flow control when buffers are full + // + // The underlying transfer_in() uses lock-free atomic allocation from the + // TransferRequest pool, making this multi-threaded access safe const auto *ep = channel->cdc_dev_.in_ep; // CALLBACK CONTEXT: This lambda is executed in USB task via transfer_callback auto callback = [this, channel](const usb_host::TransferStatus &status) {