From a0643429c1269b9c0b914f61ee058f51c945f9ad Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:46:35 +0000 Subject: [PATCH] [usb_host] Read the interface descriptor string, for full Linux parity --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.cpp | 4 +- esphome/components/api/api_pb2.h | 3 +- esphome/components/api/api_pb2_dump.cpp | 1 + esphome/components/usb_host/usb_host.h | 21 ++++++ .../components/usb_host/usb_host_client.cpp | 68 ++++++++++++++++++- esphome/components/usb_uart/cp210x.cpp | 5 +- esphome/components/usb_uart/ft23xx.cpp | 3 + esphome/components/usb_uart/pl2303.cpp | 3 +- esphome/components/usb_uart/usb_uart.cpp | 52 ++++++++++++++ esphome/components/usb_uart/usb_uart.h | 13 ++++ 11 files changed, 168 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 6de93d6ecb..a9d59f931a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2908,6 +2908,7 @@ message SerialProxyUsbInfo { string manufacturer = 8; string product = 9; string serial_number = 10; + string interface_description = 11; // iInterface string of that interface; empty when the device has none } // ==================== BLUETOOTH CONNECTION PARAMS ==================== diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 13fa91cdbd..6e00cb4038 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4288,9 +4288,10 @@ uint8_t *SerialProxyUsbInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->manufacturer); ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->product); ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->serial_number); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->interface_description); return pos; } -uint32_t SerialProxyGetUsbInfoResponse::calculate_size() const { +uint32_t SerialProxyUsbInfo::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->instance); size += this->status ? 2 : 0; @@ -4302,6 +4303,7 @@ uint32_t SerialProxyGetUsbInfoResponse::calculate_size() const { size += ProtoSize::calc_length(1, this->manufacturer.size()); size += ProtoSize::calc_length(1, this->product.size()); size += ProtoSize::calc_length(1, this->serial_number.size()); + size += ProtoSize::calc_length(1, this->interface_description.size()); return size; } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 4a0b7db55b..7a1352d88b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3443,7 +3443,7 @@ class SerialProxyGetUsbInfoRequest final : public ProtoDecodableMessage { class SerialProxyUsbInfo final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 154; - static constexpr uint8_t ESTIMATED_SIZE = 51; + static constexpr uint8_t ESTIMATED_SIZE = 60; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_usb_info"); } #endif @@ -3457,6 +3457,7 @@ class SerialProxyUsbInfo final : public ProtoMessage { StringRef manufacturer{}; StringRef product{}; StringRef serial_number{}; + StringRef interface_description{}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ede3f2350f..77dda90aea 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2842,6 +2842,7 @@ const char *SerialProxyUsbInfo::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer); dump_field(out, ESPHOME_PSTR("product"), this->product); dump_field(out, ESPHOME_PSTR("serial_number"), this->serial_number); + dump_field(out, ESPHOME_PSTR("interface_description"), this->interface_description); return out.c_str(); } #endif diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 4d1e188928..ec045bd70a 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -5,6 +5,7 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/defines.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include #include "usb/usb_host.h" #include @@ -12,6 +13,7 @@ #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" #include +#include namespace esphome::usb_host { @@ -131,6 +133,10 @@ struct UsbDeviceInfo { char serial_number[DESC_STRING_BUF_SIZE]; }; +/// Copy a USB string descriptor into a NUL-terminated buffer, dropping characters outside +/// Latin-1. A missing descriptor copies as an empty string. +void copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer); + enum ClientState { USB_CLIENT_INIT = 0, USB_CLIENT_OPEN, @@ -162,6 +168,14 @@ class USBClient : public Component { /// Returns false when no device is connected. bool get_device_info(UsbDeviceInfo &info) const; + /// Read a string descriptor the host stack does not cache, an interface string say, into + /// buffer. Uses a transfer of its own: the pooled ones hold one packet, and a string + /// descriptor can be four times that. The callback runs on the USB task once buffer holds + /// the string (empty on failure). Returns false when nothing was started. One read at a + /// time; main loop only. + bool read_string_descriptor(uint8_t index, std::span buffer, + const transfer_cb_t &callback); + /// Narrow which device this client claims, beyond the VID/PID it was constructed /// with, by requiring a descriptor string to match exactly. void set_manufacturer_filter(const char *manufacturer) { this->manufacturer_filter_ = manufacturer; } @@ -208,6 +222,8 @@ class USBClient : public Component { this->trq_in_use_.store(0); } + static void string_descriptor_callback(usb_transfer_t *xfer); + // USB task management static void usb_task_fn(void *arg); [[noreturn]] void usb_task_loop_() const; @@ -215,6 +231,11 @@ class USBClient : public Component { // Members ordered to minimize struct padding on 32-bit platforms TransferRequest requests_[MAX_REQUESTS]{}; TaskHandle_t usb_task_handle_{nullptr}; + // Dedicated transfer for read_string_descriptor(), allocated on first use and kept + usb_transfer_t *string_transfer_{nullptr}; + transfer_cb_t string_callback_; + char *string_buffer_{nullptr}; + std::atomic string_read_busy_{false}; usb_host_client_handle_t handle_{}; usb_device_handle_t device_handle_{}; int device_addr_{-1}; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 04b2b864e9..4e27c34621 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -162,7 +162,7 @@ static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { +void copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { buffer[0] = '\0'; if (desc == nullptr || desc->bLength < 2) return; @@ -215,6 +215,72 @@ bool USBClient::get_device_info(UsbDeviceInfo &info) const { return true; } +static constexpr size_t MAX_STRING_DESC_SIZE = 255; // bLength is one byte +static constexpr uint16_t LANGID_EN_US = 0x0409; + +// CALLBACK CONTEXT: USB task +void USBClient::string_descriptor_callback(usb_transfer_t *xfer) { + auto *client = static_cast(xfer->context); + TransferStatus status{}; + status.error_code = xfer->status; + status.success = xfer->status == USB_TRANSFER_STATUS_COMPLETED; + status.endpoint = xfer->bEndpointAddress; + // The buffer starts with the setup packet; the descriptor follows it + status.data = xfer->data_buffer + SETUP_PACKET_SIZE; + status.data_len = + xfer->actual_num_bytes > static_cast(SETUP_PACKET_SIZE) ? xfer->actual_num_bytes - SETUP_PACKET_SIZE : 0; + const auto *desc = reinterpret_cast(status.data); + // A short read leaves bLength claiming bytes that never arrived; treat it as missing + const bool complete = status.success && status.data_len >= 2 && desc->bLength <= status.data_len; + copy_descriptor_string(complete ? desc : nullptr, + std::span(client->string_buffer_, DESC_STRING_BUF_SIZE)); + client->string_read_busy_.store(false); + if (client->string_callback_ != nullptr) { + client->string_callback_(status); + } +} + +bool USBClient::read_string_descriptor(uint8_t index, std::span buffer, + const transfer_cb_t &callback) { + buffer[0] = '\0'; + if (this->state_ != USB_CLIENT_CONNECTED || index == 0) { + return false; + } + if (this->string_transfer_ == nullptr) { + if (usb_host_transfer_alloc(SETUP_PACKET_SIZE + MAX_STRING_DESC_SIZE, 0, &this->string_transfer_) != ESP_OK) { + ESP_LOGE(TAG, "String descriptor transfer alloc failed"); + return false; + } + this->string_transfer_->context = this; + this->string_transfer_->callback = string_descriptor_callback; + } + bool idle = false; + if (!this->string_read_busy_.compare_exchange_strong(idle, true)) { + ESP_LOGW(TAG, "String descriptor read already in progress"); + return false; + } + auto *setup = reinterpret_cast(this->string_transfer_->data_buffer); + setup->bmRequestType = USB_DIR_IN | USB_TYPE_STANDARD | USB_RECIP_DEVICE; + setup->bRequest = USB_B_REQUEST_GET_DESCRIPTOR; + setup->wValue = (USB_B_DESCRIPTOR_TYPE_STRING << 8) | index; + // The host stack read the device strings in US English; Linux asks for it first as well, + // so all three report the same text + setup->wIndex = LANGID_EN_US; + setup->wLength = MAX_STRING_DESC_SIZE; + this->string_transfer_->num_bytes = SETUP_PACKET_SIZE + MAX_STRING_DESC_SIZE; + this->string_transfer_->bEndpointAddress = USB_DIR_IN; + this->string_transfer_->device_handle = this->device_handle_; + this->string_buffer_ = buffer.data(); + this->string_callback_ = callback; + auto err = usb_host_transfer_submit_control(this->handle_, this->string_transfer_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to submit string descriptor read, err=%s", esp_err_to_name(err)); + this->string_read_busy_.store(false); + return false; + } + return true; +} + // CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) { auto *client = static_cast(ptr); diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 5551abe1a1..c8f0967bd0 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -88,10 +88,11 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; } + // No communication interface: the data interface is the one a host binds to if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) { - cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}}); + cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber, 0xFF, 0, data_desc->iInterface}}); } else { - cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}}); + cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber, 0xFF, 0, data_desc->iInterface}}); } } return cdc_devs; diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index fcebf0fbd9..e4fc4950a1 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -217,6 +217,9 @@ static optional get_uart(const usb_config_desc_t *config_desc, uint8_t i } eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_string_index = intf_desc->iInterface; + // No communication interface: the data interface is the one a host binds to + eps.interrupt_interface_number = 0xFF; return eps; } diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index db177fd308..598fcf6578 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -189,7 +189,8 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev } if (in_ep && out_ep) { - cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); + cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber, + intf->iInterface, intf->iInterface}); break; // PL2303 is single-port } } diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 75badfc0d5..0f6b310735 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -43,14 +43,17 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { eps.notify_ep = ep; eps.interrupt_interface_number = intf_desc->bInterfaceNumber; + eps.interrupt_interface_string_index = intf_desc->iInterface; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.in_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_string_index = intf_desc->iInterface; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.out_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_string_index = intf_desc->iInterface; } else { ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes); continue; @@ -479,6 +482,7 @@ void USBUartTypeCdcAcm::on_disconnected() { channel->input_started_.store(true); channel->output_started_.store(true); channel->input_buffer_.clear(); + channel->interface_string_[0] = '\0'; // Drain any pending output chunks and return them to the pool { UsbOutputChunk *chunk; @@ -557,11 +561,38 @@ void USBUartComponent::start_config_(bool reload) { this->cfg_step_ = 0; this->cfg_ok_ = true; this->cfg_in_flight_ = false; + this->cfg_string_done_ = false; + this->cfg_string_in_flight_ = false; this->cfg_done_.store(false); this->cfg_active_ = true; this->enable_loop(); } +bool USBUartComponent::fetch_interface_string_(USBUartChannelBase *channel) { + const CdcEps &eps = channel->cdc_dev_; + const uint8_t index = + eps.interrupt_interface_number != 0xFF ? eps.interrupt_interface_string_index : eps.bulk_interface_string_index; + channel->interface_string_[0] = '\0'; + if (index == 0) { + return false; + } + this->cfg_done_.store(false); + const bool submitted = + this->read_string_descriptor(index, channel->interface_string_, [this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGW(TAG, "Interface string read failed: %s", esp_err_to_name(status.error_code)); + } + // Release: publishes the string before the loop observes cfg_done_. + this->cfg_done_.store(true, std::memory_order_release); + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); + }); + if (!submitted) { + ESP_LOGW(TAG, "Interface string read submit failed"); + } + return submitted; +} + void USBUartComponent::config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const std::vector &data) { this->cfg_done_.store(false); @@ -595,6 +626,14 @@ bool USBUartComponent::run_config_machine_() { if (!this->cfg_active_) return false; + if (this->cfg_string_in_flight_) { + // Acquire: pairs with the release in fetch_interface_string_'s callback. + if (!this->cfg_done_.load(std::memory_order_acquire)) + return false; + this->cfg_string_in_flight_ = false; + this->cfg_done_.store(false); + } + if (this->cfg_in_flight_) { // Acquire: pairs with the release in config_transfer_'s callback. if (!this->cfg_done_.load(std::memory_order_acquire)) @@ -625,6 +664,16 @@ bool USBUartComponent::run_config_machine_() { ? this->cfg_single_ : (this->cfg_channel_idx_ < this->channels_.size() ? this->channels_[this->cfg_channel_idx_] : nullptr); + // Once per channel on init, before its settings: the interface string is part of the + // identity reported to clients, and the connected report waits for this machine. + if (channel != nullptr && !this->cfg_reload_ && !this->cfg_string_done_) { + this->cfg_string_done_ = true; + if (this->fetch_interface_string_(channel)) { + this->cfg_string_in_flight_ = true; + return true; + } + } + if (channel != nullptr && channel->initialised_.load()) { if (!this->cfg_ok_) { // A previous step in this channel's sequence failed. Abort the rest. On a full init, @@ -648,11 +697,14 @@ bool USBUartComponent::run_config_machine_() { // Advance to the next channel (or finish). this->cfg_step_ = 0; this->cfg_ok_ = true; + this->cfg_string_done_ = false; if (this->cfg_single_ != nullptr) { this->cfg_active_ = false; this->cfg_single_ = nullptr; } else if (++this->cfg_channel_idx_ >= this->channels_.size()) { this->cfg_active_ = false; + // Init is done and the line settings are on the wire: now the device is ready to use + this->report_connected_(); } // If the machine just went idle and a reload was requested while it was busy, start it now. diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 139315841e..efdcaf13cf 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -35,6 +35,9 @@ struct CdcEps { const usb_ep_desc_t *out_ep; uint8_t bulk_interface_number; uint8_t interrupt_interface_number; + // iInterface of each interface; 0 when the device provides no string for it + uint8_t interrupt_interface_string_index; + uint8_t bulk_interface_string_index; }; enum CH34xChipType : uint8_t { @@ -174,11 +177,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedcdc_dev_.bulk_interface_number; } + /// iInterface string of that interface; empty when the device has none, or until it has + /// been read after the device connected + const char *get_interface_string() const { return this->interface_string_; } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} void check_logger_conflict() override {} // Larger structures first (8+ bytes) + char interface_string_[usb_host::DESC_STRING_BUF_SIZE]{}; RingBuffer input_buffer_; LockFreeQueue output_queue_; // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring @@ -248,6 +256,9 @@ class USBUartComponent : public usb_host::USBClient { void start_config_(bool reload); // Advance the config state machine; called from loop(). Returns true if it did work. bool run_config_machine_(); + // Ask the device for the channel's interface string. Returns true when a transfer was + // submitted; its completion is signalled through cfg_done_ like a config step's. + bool fetch_interface_string_(USBUartChannelBase *channel); // Per-subclass per-channel settings sequence. For the given zero-based step, issue the // next control transfer via config_transfer_() and return true, or return false when the @@ -277,6 +288,8 @@ class USBUartComponent : public usb_host::USBClient { bool cfg_device_phase_{false}; bool cfg_in_flight_{false}; bool cfg_ok_{true}; + bool cfg_string_done_{false}; + bool cfg_string_in_flight_{false}; }; class USBUartTypeCdcAcm : public USBUartComponent {