diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 9d79b0ec23..a9d59f931a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2886,7 +2886,14 @@ message SerialProxyGetUsbInfoRequest { // The USB identity of the device currently behind a port, read live from the cached // USB descriptors. Fields are zero/empty while no device is connected. -message SerialProxyGetUsbInfoResponse { +// +// Sent in reply to SerialProxyGetUsbInfoRequest, and also unsolicited to every connected +// client whenever a USB device is attached to or removed from a USB_SERIAL port. A client +// therefore subscribes to this message before querying the current state, so the +// connect-time query and later hotplug events are handled by one path. The port's +// subscription is untouched by a hotplug: the session belongs to the client, which decides +// whether to keep it. +message SerialProxyUsbInfo { option (id) = 154; option (source) = SOURCE_SERVER; option (ifdef) = "USE_SERIAL_PROXY"; @@ -2897,10 +2904,11 @@ message SerialProxyGetUsbInfoResponse { uint32 vendor_id = 4; uint32 product_id = 5; uint32 bcd_device = 6; - uint32 interface_number = 7; // Channel index on multi-port bridges + uint32 interface_number = 7; // bInterfaceNumber a host driver binds to, as Linux reports it 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_connection.cpp b/esphome/components/api/api_connection.cpp index 6d08e0d98c..eb9f0d3578 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1647,14 +1647,14 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM void APIConnection::on_serial_proxy_get_usb_info_request(const SerialProxyGetUsbInfoRequest &msg) { auto &proxies = App.get_serial_proxies(); - SerialProxyGetUsbInfoResponse resp{}; + SerialProxyUsbInfo resp{}; resp.instance = msg.instance; if (msg.instance >= proxies.size()) { ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT; } else { #ifdef USE_SERIAL_PROXY_USB_INFO - // The response's strings are views into this buffer, which outlives the send below + // The message's strings are views into this buffer, which outlives the send below usb_host::UsbDeviceInfo info; proxies[msg.instance]->get_usb_info(info, resp); #else diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 1a0aaefd6a..6e00cb4038 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4276,7 +4276,7 @@ bool SerialProxyGetUsbInfoRequest::decode_varint(uint32_t field_id, proto_varint } return true; } -uint8_t *SerialProxyGetUsbInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SerialProxyUsbInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->status)); @@ -4288,9 +4288,10 @@ uint8_t *SerialProxyGetUsbInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_EN 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 7fa1560a02..7a1352d88b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3440,12 +3440,12 @@ class SerialProxyGetUsbInfoRequest final : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; -class SerialProxyGetUsbInfoResponse final : public ProtoMessage { +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_get_usb_info_response"); } + const LogString *message_name() const override { return LOG_STR("serial_proxy_usb_info"); } #endif uint32_t instance{0}; enums::SerialProxyStatus status{}; @@ -3457,6 +3457,7 @@ class SerialProxyGetUsbInfoResponse 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 b264c917ac..77dda90aea 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2830,8 +2830,8 @@ const char *SerialProxyGetUsbInfoRequest::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("instance"), this->instance); return out.c_str(); } -const char *SerialProxyGetUsbInfoResponse::dump_to(DumpBuffer &out) const { - MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetUsbInfoResponse")); +const char *SerialProxyUsbInfo::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyUsbInfo")); dump_field(out, ESPHOME_PSTR("instance"), this->instance); dump_field(out, ESPHOME_PSTR("status"), static_cast(this->status)); dump_field(out, ESPHOME_PSTR("connected"), this->connected); @@ -2842,6 +2842,7 @@ const char *SerialProxyGetUsbInfoResponse::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/api/api_server.cpp b/esphome/components/api/api_server.cpp index 78ebe5c38e..3b606f13dd 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -404,6 +404,18 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { } #endif +#ifdef USE_SERIAL_PROXY_USB_INFO +void APIServer::send_serial_proxy_usb_info(const SerialProxyUsbInfo &msg) { + // Unsolicited: a hotplug is rare and the message small, so every client hears of it + // rather than maintaining a subscription for the one client there normally is + for (auto &c : this->active_clients()) { + if (!c->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "USB info notification"); + } + } +} +#endif + #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key, const std::vector *timings) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 618ea4eb11..0d1c9e4cc2 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -194,6 +194,10 @@ class APIServer final : public Component, #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const ZWaveProxyRequest &msg); #endif +#ifdef USE_SERIAL_PROXY_USB_INFO + /// Tell every client that the USB device behind a serial proxy port changed + void send_serial_proxy_usb_info(const SerialProxyUsbInfo &msg); +#endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 6a9208c01b..cd9e1ae4b9 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -34,6 +34,13 @@ void SerialProxy::setup() { // instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data this->outgoing_msg_.instance = this->instance_index_; #endif +#ifdef USE_SERIAL_PROXY_USB_INFO + // The define is global, so a hardware UART port in the same config also gets here + if (this->usb_channel_ != nullptr) { + this->usb_channel_->get_parent()->add_on_connection_callback( + [this](bool connected) { this->on_usb_connection_changed_(connected); }); + } +#endif #ifdef USE_SERIAL_PROXY_TAP // A tap sets itself up before this runs (its setup priority is higher), so it may // already be waiting on the port -- a boot-time handshake with the device, say. Leaving @@ -205,11 +212,6 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin ESP_LOGW(TAG, "Invalid parity: %u (must be 0-2)", parity); return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT; } - if (flow_control) { - ESP_LOGW(TAG, "Hardware flow control requested but is not yet supported"); - return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED; - } - // Skip a no-op reconfigure. Clients routinely re-send identical settings on every // port open, and on a USB UART each apply is a CDC SET_LINE_CODING control transfer. // Some bridges watch line-coding changes as a signalling channel (a magic baud @@ -220,7 +222,8 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin uart::UART_CONFIG_PARITY_ODD, }; if (uart_comp->get_baud_rate() == baudrate && uart_comp->get_stop_bits() == stop_bits && - uart_comp->get_data_bits() == data_size && uart_comp->get_parity() == PARITY_MAP[parity]) { + uart_comp->get_data_bits() == data_size && uart_comp->get_parity() == PARITY_MAP[parity] && + uart_comp->get_flow_control() == flow_control) { ESP_LOGV(TAG, "Settings unchanged, skipping reconfigure [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } @@ -229,6 +232,7 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin uart_comp->set_baud_rate(baudrate); uart_comp->set_stop_bits(stop_bits); uart_comp->set_data_bits(data_size); + uart_comp->set_flow_control(flow_control); uart_comp->set_parity(PARITY_MAP[parity]); @@ -338,26 +342,54 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } -#if defined(USE_SERIAL_PROXY_USB_INFO) && defined(USE_API) -void SerialProxy::get_usb_info(usb_host::UsbDeviceInfo &info, api::SerialProxyGetUsbInfoResponse &resp) const { +#ifdef USE_SERIAL_PROXY_USB_INFO +void SerialProxy::on_usb_connection_changed_(bool connected) { + ESP_LOGD(TAG, "USB device %s serial proxy [%" PRIu32 "]", + connected ? LOG_STR_LITERAL("attached to") : LOG_STR_LITERAL("removed from"), this->instance_index_); +#ifdef USE_SERIAL_PROXY_TAP + // Before telling clients, so a tap never acknowledges a frame from the old device after + // a client has been told it is gone. The subscriber and the mode stay: both belong to + // the client's session, and only the client knows whether that session is over. + if (!connected && this->tap_ != nullptr) { + this->tap_->on_device_disconnected(); + } +#endif +#ifdef USE_API + if (api::global_api_server == nullptr) { + return; + } + // The message's strings are views into this buffer, which outlives the send below + usb_host::UsbDeviceInfo info; + api::SerialProxyUsbInfo msg{}; + msg.instance = this->instance_index_; + this->get_usb_info(info, msg); + api::global_api_server->send_serial_proxy_usb_info(msg); +#endif +} + +#ifdef USE_API +void SerialProxy::get_usb_info(usb_host::UsbDeviceInfo &info, api::SerialProxyUsbInfo &msg) const { if (this->usb_channel_ == nullptr) { - resp.status = api::enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED; + msg.status = api::enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED; return; } - resp.interface_number = this->usb_channel_->get_index(); if (!this->usb_channel_->get_parent()->get_device_info(info)) { // No device attached right now; not an error return; } - resp.connected = true; - resp.vendor_id = info.vendor_id; - resp.product_id = info.product_id; - resp.bcd_device = info.bcd_device; - resp.manufacturer = StringRef(info.manufacturer); - resp.product = StringRef(info.product); - resp.serial_number = StringRef(info.serial_number); + msg.connected = true; + msg.vendor_id = info.vendor_id; + msg.product_id = info.product_id; + msg.bcd_device = info.bcd_device; + msg.interface_number = this->usb_channel_->get_interface_number(); + msg.manufacturer = StringRef(info.manufacturer); + msg.product = StringRef(info.product); + msg.serial_number = StringRef(info.serial_number); + // Lives in the channel for as long as the device is attached + msg.interface_description = StringRef(this->usb_channel_->get_interface_string()); } #endif +#endif uint32_t SerialProxy::get_modem_pins() const { return (this->rts_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 07c7b643cb..75a5016150 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -89,6 +89,11 @@ class SerialProxyTap { /// else with the device -- reflash it, most likely -- so anything the tap believes about /// it should be treated as suspect. virtual void on_protocol_disabled() = 0; + + /// The device behind the port went away -- unplugged, or its power was cut. Whatever + /// session the tap had observed ended with it; the device that appears next starts from + /// scratch and may not even be the same one. Only a USB UART can report this. + virtual void on_device_disconnected() = 0; }; #endif @@ -123,7 +128,7 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Configure UART parameters and apply them /// @param api_connection The API connection requesting the change /// @param baudrate Baud rate in bits per second - /// @param flow_control True to enable hardware flow control + /// @param flow_control True to request hardware flow control /// @param parity Parity setting (0=none, 1=even, 2=odd) /// @param stop_bits Number of stop bits (1 or 2) /// @param data_size Number of data bits (5-8) @@ -169,9 +174,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; } #ifdef USE_API - /// Fill a USB info response for this port. The response's strings are views into - /// info, so info must outlive the send. - void get_usb_info(usb_host::UsbDeviceInfo &info, api::SerialProxyGetUsbInfoResponse &resp) const; + /// Fill a USB info message for this port. The message's strings are views into info, + /// so info must outlive the send. + void get_usb_info(usb_host::UsbDeviceInfo &info, api::SerialProxyUsbInfo &msg) const; #endif #endif @@ -238,6 +243,12 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool tap_observing_() const; #endif +#ifdef USE_SERIAL_PROXY_USB_INFO + /// The USB device behind this port was attached or removed. Reports the port's new USB + /// identity to every API client, and ends the tap's view of the old device. + void on_usb_connection_changed_(bool connected); +#endif + /// Instance index for identifying this proxy in API messages uint32_t instance_index_{0}; diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 3e52531791..01b17624b9 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -166,6 +166,14 @@ class UARTComponent { // @return Baud rate in bits per second. uint32_t get_baud_rate() const { return baud_rate_; } + // Requests hardware (RTS/CTS) flow control. + // @param flow_control True to request hardware flow control. + void set_flow_control(bool flow_control) { this->flow_control_ = flow_control; } + + // Gets whether hardware flow control was requested. + // @return True when hardware flow control was requested. + bool get_flow_control() const { return this->flow_control_; } + #if defined(USE_ESP8266) || defined(USE_ESP32) /** * Load the UART settings. @@ -213,6 +221,7 @@ class UARTComponent { uint8_t stop_bits_{0}; uint8_t data_bits_{0}; UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; + bool flow_control_{false}; #ifdef USE_UART_DEBUGGER CallbackManager debug_callback_{}; #endif diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 5aa3763f03..213f16286d 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -68,13 +68,11 @@ def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: for index, first in enumerate(configs): # Ensure matching logic does not overlap between entries for second in configs[index + 1 :]: - if ( - not (first[CONF_VID] == 0 and first[CONF_PID] == 0) - and not (second[CONF_VID] == 0 and second[CONF_PID] == 0) - and ( - first[CONF_VID] != second[CONF_VID] - or first[CONF_PID] != second[CONF_PID] - ) + # A zero VID or PID is a wildcard for that field, so only a differing non-zero + # value separates two entries + if not all( + first[key] == 0 or second[key] == 0 or first[key] == second[key] + for key in (CONF_VID, CONF_PID) ): continue diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 088b222c0e..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,11 +168,28 @@ 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; } void set_product_filter(const char *product) { this->product_filter_ = product; } + /// Register a callback for the device this client claims being connected (true) or + /// removed (false). Fires only for a device that passed every filter and was fully + /// opened, so a device another client claims is never reported. Called from the main + /// loop: connected once the device is ready to use (a subclass may hold this back until + /// its own setup of the device has finished), removed after on_disconnected() has run. + template void add_on_connection_callback(F &&callback) { + this->connection_callback_.add(std::forward(callback)); + } + // Lock-free event queue and pool for USB task to main loop communication // Must be public for access from static callbacks LockFreeQueue event_queue; @@ -187,11 +210,20 @@ class USBClient : public Component { /// Whether the device's descriptor strings satisfy every filter that is set. bool descriptor_strings_match_(const usb_device_info_t &dev_info) const; + + /// Whether the subclass reports the device as connected itself, once its own setup of + /// the device has finished, rather than as soon as the device has been opened + virtual bool reports_connection_itself() const { return false; } + /// Report the claimed device to the connection callbacks. Idempotent; a subclass that + /// reports itself calls this once the device is ready to use. + void report_connected_(); virtual void on_disconnected() { // Reset all requests to available (all bits to 0) 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; @@ -199,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}; @@ -207,11 +244,15 @@ class USBClient : public Component { // Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available // Supports multiple concurrent consumers and producers (both threads can allocate/deallocate) std::atomic trq_in_use_; + LazyCallbackManager connection_callback_; // Descriptor strings a device must report to be claimed; nullptr means no constraint const char *manufacturer_filter_{nullptr}; const char *product_filter_{nullptr}; uint16_t vid_{}; uint16_t pid_{}; + // Whether the connection callbacks were told about the current device, so a removal is + // only ever reported for a device that was reported connected + bool connection_reported_{false}; }; class USBHost final : public Component { public: diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 76daf68b02..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); @@ -355,12 +421,10 @@ void USBClient::handle_open_state_() { return; } ESP_LOGD(TAG, "Device descriptor: vid %X pid %X", desc->idVendor, desc->idProduct); - if (desc->idVendor != this->vid_ || desc->idProduct != this->pid_) { - if (this->vid_ != 0 || this->pid_ != 0) { - ESP_LOGD(TAG, "Not our device, closing"); - this->disconnect(); - return; - } + if ((this->vid_ != 0 && desc->idVendor != this->vid_) || (this->pid_ != 0 && desc->idProduct != this->pid_)) { + ESP_LOGD(TAG, "Not our device, closing"); + this->disconnect(); + return; } usb_device_info_t dev_info; err = usb_host_device_info(this->device_handle_, &dev_info); @@ -399,6 +463,18 @@ void USBClient::handle_open_state_() { usb_client_print_config_descriptor(config_desc, nullptr); #endif this->on_connected(); + // on_connected() may have rejected the device (no usable interface, say) and closed it + if (this->state_ == USB_CLIENT_CONNECTED && !this->reports_connection_itself()) { + this->report_connected_(); + } +} + +void USBClient::report_connected_() { + if (this->state_ != USB_CLIENT_CONNECTED || this->connection_reported_) { + return; + } + this->connection_reported_ = true; + this->connection_callback_.call(true); } void USBClient::on_opened(uint8_t addr) { @@ -469,6 +545,10 @@ TransferRequest *USBClient::get_trq_() { } void USBClient::disconnect() { + // Also reached for a device this client opened and then declined, or lost before it was + // ready; neither was reported as connected, so neither is reported as removed + const bool was_reported = this->connection_reported_; + this->connection_reported_ = false; this->on_disconnected(); auto err = usb_host_device_close(this->handle_, this->device_handle_); if (err != ESP_OK) { @@ -477,6 +557,9 @@ void USBClient::disconnect() { this->state_ = USB_CLIENT_INIT; this->device_handle_ = nullptr; this->device_addr_ = -1; + if (was_reported) { + this->connection_callback_.call(false); + } } // THREAD CONTEXT: Called from main loop thread only 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 818b74a714..0350c4b170 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; @@ -481,6 +484,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; @@ -559,11 +563,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); @@ -597,6 +628,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)) @@ -627,6 +666,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, @@ -650,11 +699,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 1c11dd8016..06d2b05c8e 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -37,6 +37,9 @@ struct CdcEps { // Also the wIndex target for CDC class requests (SET_LINE_CODING etc.), so it // must remain valid even when the interface itself is not claimed. 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; bool interrupt_interface_claimed{false}; }; @@ -167,14 +170,26 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented cb) { this->rx_callback_ = std::move(cb); } - /// Channel index on the bridge (interface number on multi-port bridges) + /// Channel index on the bridge uint8_t get_index() const { return this->index_; } + /// USB interface number a host driver binds to for this channel: the communication + /// interface of a CDC ACM function, otherwise the data interface. + uint8_t get_interface_number() const { + return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_number + : this->cdc_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 @@ -244,6 +259,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 @@ -255,6 +273,10 @@ class USBUartComponent : public usb_host::USBClient { // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + // The device is only usable once the config machine has applied every channel's line + // settings, so the connected report waits for run_config_machine_() to finish the init + bool reports_connection_itself() const override { return true; } + std::vector channels_{}; // Config state machine @@ -269,6 +291,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 { diff --git a/esphome/components/zigbee_proxy_tap/zigbee_proxy_tap.cpp b/esphome/components/zigbee_proxy_tap/zigbee_proxy_tap.cpp index ae82410e62..8edff1bfc2 100644 --- a/esphome/components/zigbee_proxy_tap/zigbee_proxy_tap.cpp +++ b/esphome/components/zigbee_proxy_tap/zigbee_proxy_tap.cpp @@ -56,6 +56,16 @@ void ZigbeeProxyTap::on_protocol_disabled() { this->detector_.reset(); } +void ZigbeeProxyTap::on_device_disconnected() { + // The ASH session died with the NCP's power. Staying armed would acknowledge frames from + // whatever boots next, before its own RSTACK has proven it speaks ASH at all. + if (this->was_armed_) { + ESP_LOGD(TAG, "Device removed, no longer acknowledging frames"); + this->was_armed_ = false; + } + this->detector_.reset(); +} + } // namespace esphome::zigbee_proxy_tap #endif // USE_ZIGBEE_PROXY_TAP diff --git a/esphome/components/zigbee_proxy_tap/zigbee_proxy_tap.h b/esphome/components/zigbee_proxy_tap/zigbee_proxy_tap.h index 702ae12eb7..1cae8cd1df 100644 --- a/esphome/components/zigbee_proxy_tap/zigbee_proxy_tap.h +++ b/esphome/components/zigbee_proxy_tap/zigbee_proxy_tap.h @@ -31,6 +31,7 @@ class ZigbeeProxyTap : public serial_proxy::SerialProxyTap, public Component { // there is nothing to do and the port need not be read. bool tap_needs_port() const override { return false; } void on_protocol_disabled() override; + void on_device_disconnected() override; protected: // The port this component observes. Owns the UART and the bytes; every write we make diff --git a/esphome/components/zwave_proxy_tap/zwave_proxy_tap.cpp b/esphome/components/zwave_proxy_tap/zwave_proxy_tap.cpp index d4b35c3d24..29797e44fc 100644 --- a/esphome/components/zwave_proxy_tap/zwave_proxy_tap.cpp +++ b/esphome/components/zwave_proxy_tap/zwave_proxy_tap.cpp @@ -57,6 +57,16 @@ void ZWaveProxyTap::on_protocol_disabled() { this->detector_.reset(); } +void ZWaveProxyTap::on_device_disconnected() { + // The controller lost power, so the exchange we saw belongs to a session that no longer + // exists. Whatever appears next has to prove itself again. + if (this->was_armed_) { + ESP_LOGD(TAG, "Device removed, no longer acknowledging frames"); + this->was_armed_ = false; + } + this->detector_.reset(); +} + } // namespace esphome::zwave_proxy_tap #endif // USE_ZWAVE_PROXY_TAP diff --git a/esphome/components/zwave_proxy_tap/zwave_proxy_tap.h b/esphome/components/zwave_proxy_tap/zwave_proxy_tap.h index 16066b30a1..ff95b411db 100644 --- a/esphome/components/zwave_proxy_tap/zwave_proxy_tap.h +++ b/esphome/components/zwave_proxy_tap/zwave_proxy_tap.h @@ -34,6 +34,7 @@ class ZWaveProxyTap : public serial_proxy::SerialProxyTap, public Component { // there is nothing to do and the port need not be read. bool tap_needs_port() const override { return false; } void on_protocol_disabled() override; + void on_device_disconnected() override; protected: // The port this component observes. Owns the UART and the bytes; every write we make diff --git a/tests/unit_tests/components/test_usb_host.py b/tests/unit_tests/components/test_usb_host.py index 3d6b8dc1c0..39deec1f51 100644 --- a/tests/unit_tests/components/test_usb_host.py +++ b/tests/unit_tests/components/test_usb_host.py @@ -70,6 +70,36 @@ from esphome.types import ConfigType }, id="different_manufacturer", ), + pytest.param( + { + "id": "a", + "vid": 0x303A, + "pid": 0, + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + }, + { + "id": "b", + "vid": 0x303A, + "pid": 0x4001, + "manufacturer": "Nabu Casa", + "product": "ZWA-2", + }, + id="wildcard_pid_separated_by_product", + ), + pytest.param( + { + "id": "a", + "vid": 0, + "pid": 0x4001, + }, + { + "id": "b", + "vid": 0x303A, + "pid": 0x4002, + }, + id="wildcard_vid_different_pid", + ), ], ) def test_disjoint_clients_are_accepted(first: ConfigType, second: ConfigType) -> None: @@ -140,6 +170,34 @@ def test_disjoint_clients_are_accepted(first: ConfigType, second: ConfigType) -> }, id="zero_ids_match_every_device", ), + pytest.param( + { + "id": "a", + "vid": 0x303A, + "pid": 0, + }, + { + "id": "b", + "vid": 0x303A, + "pid": 0x4001, + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + }, + id="wildcard_pid_shadows_filtered", + ), + pytest.param( + { + "id": "a", + "vid": 0, + "pid": 0x4001, + }, + { + "id": "b", + "vid": 0x303A, + "pid": 0, + }, + id="wildcards_on_different_fields", + ), ], ) def test_overlapping_clients_are_rejected(