From 590a4a6268d62346bc6fe33d31cdc32e851fa674 Mon Sep 17 00:00:00 2001 From: kbx81 Date: Thu, 3 Sep 2026 22:07:52 -0500 Subject: [PATCH] [serial_proxy] Add USB identity query for USB-bridged ports Add SERIAL_PROXY_PORT_TYPE_USB_SERIAL, derived automatically when a port's uart_id resolves to a usb_uart channel (never set by the user), and a SerialProxyGetUsbInfoRequest/Response pair (IDs 153/154) that reads VID/PID/bcdDevice and the manufacturer/product/serial strings live from the descriptors the USB host stack caches, so a client can identify the attached device before subscribing. Ports that are not USB_SERIAL answer NOT_SUPPORTED; an unplugged device answers with connected=false. ZigbeeProxyRequest moves to ID 155 (expected merge order: set_mode, USB info, zigbee). --- esphome/components/api/api.proto | 38 +++++++++++++++- esphome/components/api/api_connection.cpp | 24 +++++++++++ esphome/components/api/api_connection.h | 1 + esphome/components/api/api_pb2.cpp | 38 ++++++++++++++++ esphome/components/api/api_pb2.h | 43 ++++++++++++++++++- esphome/components/api/api_pb2_dump.cpp | 21 +++++++++ esphome/components/api/api_pb2_service.cpp | 11 +++++ esphome/components/api/api_pb2_service.h | 4 ++ esphome/components/serial_proxy/__init__.py | 36 ++++++++++++++-- .../components/serial_proxy/serial_proxy.cpp | 32 ++++++++++++-- .../components/serial_proxy/serial_proxy.h | 25 +++++++++++ esphome/components/usb_host/usb_host.h | 18 ++++++++ .../components/usb_host/usb_host_client.cpp | 41 ++++++++++++++++-- esphome/components/usb_uart/__init__.py | 12 +++++- esphome/components/usb_uart/usb_uart.h | 3 ++ esphome/core/defines.h | 4 ++ .../serial_proxy/test-usb.esp32-s3-idf.yaml | 21 +++++++++ .../zigbee_proxy/test-usb.esp32-s3-idf.yaml | 1 - 18 files changed, 359 insertions(+), 14 deletions(-) create mode 100644 tests/components/serial_proxy/test-usb.esp32-s3-idf.yaml diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 8d6737fe81..aae8b30c50 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -78,6 +78,7 @@ service APIConnection { rpc serial_proxy_write(SerialProxyWriteRequest) returns (void) {} rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {} rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {} + rpc serial_proxy_get_usb_info(SerialProxyGetUsbInfoRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} } @@ -230,6 +231,11 @@ enum SerialProxyPortType { SERIAL_PROXY_PORT_TYPE_TTL = 0; SERIAL_PROXY_PORT_TYPE_RS232 = 1; SERIAL_PROXY_PORT_TYPE_RS485 = 2; + // A serial device attached through a USB bridge. Set by the device configuration, never + // by the user; identifies ports whose USB identity can be read with + // SerialProxyGetUsbInfoRequest. Deliberately not a USB endpoint type: serial_proxy + // carries serial devices only, whatever bridge chip connects them. + SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3; } message SerialProxyInfo { @@ -2873,6 +2879,36 @@ message SerialProxySetModeRequest { SerialProxyMode mode = 2; } +// Ask for the USB identity of the device behind a USB_SERIAL port. Read-only, so no +// subscription is required -- a client typically uses this to decide which port to +// subscribe to. Answered with NOT_SUPPORTED on ports that are not USB_SERIAL. +message SerialProxyGetUsbInfoRequest { + option (id) = 153; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; +} + +// 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 { + option (id) = 154; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyStatus status = 2; // NOT_SUPPORTED when the port is not USB_SERIAL + bool connected = 3; // True when a USB device is currently attached + uint32 vendor_id = 4; + uint32 product_id = 5; + uint32 bcd_device = 6; + uint32 interface_number = 7; // Channel index on multi-port bridges + string manufacturer = 8; + string product = 9; + string serial_number = 10; +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== message BluetoothSetConnectionParamsRequest { option (id) = 145; @@ -2902,7 +2938,7 @@ enum ZigbeeProxyRequestType { } message ZigbeeProxyRequest { - option (id) = 153; + option (id) = 155; option (source) = SOURCE_BOTH; option (ifdef) = "USE_ZIGBEE_PROXY"; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 59aa93e126..7f42e32b53 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -51,6 +51,9 @@ #ifdef USE_ZIGBEE_PROXY #include "esphome/components/zigbee_proxy/zigbee_proxy.h" #endif +#ifdef USE_SERIAL_PROXY_USB_INFO +#include "esphome/components/usb_host/usb_host.h" +#endif #ifdef USE_WATER_HEATER #include "esphome/components/water_heater/water_heater.h" #endif @@ -1651,6 +1654,27 @@ 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{}; + 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 + usb_host::UsbDeviceInfo info; + proxies[msg.instance]->get_usb_info(info, resp); +#else + resp.status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED; +#endif + } + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } +} + void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index cf3ebe5af5..7d82156b2f 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -247,6 +247,7 @@ class APIConnection final : public APIServerConnectionBase { void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg); void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg); void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg); + void on_serial_proxy_get_usb_info_request(const SerialProxyGetUsbInfoRequest &msg); void on_serial_proxy_request(const SerialProxyRequest &msg); void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg); void send_serial_proxy_data(const SerialProxyDataReceived &msg); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 2547f67016..f8174e298a 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4278,6 +4278,44 @@ bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_va } return true; } +bool SerialProxyGetUsbInfoRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { + switch (field_id) { + case 1: + this->instance = value; + break; + default: + return false; + } + return true; +} +uint8_t *SerialProxyGetUsbInfoResponse::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)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->connected); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->vendor_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->product_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->bcd_device); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->interface_number); + 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); + return pos; +} +uint32_t SerialProxyGetUsbInfoResponse::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->instance); + size += this->status ? 2 : 0; + size += ProtoSize::calc_bool(1, this->connected); + size += ProtoSize::calc_uint32(1, this->vendor_id); + size += ProtoSize::calc_uint32(1, this->product_id); + size += ProtoSize::calc_uint32(1, this->bcd_device); + size += ProtoSize::calc_uint32(1, this->interface_number); + 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()); + return size; +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7755d82966..43248ffb5d 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -23,6 +23,7 @@ enum SerialProxyPortType : uint32_t { SERIAL_PROXY_PORT_TYPE_TTL = 0, SERIAL_PROXY_PORT_TYPE_RS232 = 1, SERIAL_PROXY_PORT_TYPE_RS485 = 2, + SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3, }; enum EntityCategory : uint32_t { ENTITY_CATEGORY_NONE = 0, @@ -3435,6 +3436,46 @@ class SerialProxySetModeRequest final : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; +class SerialProxyGetUsbInfoRequest final : public ProtoDecodableMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 153; + static constexpr uint8_t ESTIMATED_SIZE = 4; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_get_usb_info_request"); } +#endif + uint32_t instance{0}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; +}; +class SerialProxyGetUsbInfoResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 154; + static constexpr uint8_t ESTIMATED_SIZE = 51; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_get_usb_info_response"); } +#endif + uint32_t instance{0}; + enums::SerialProxyStatus status{}; + bool connected{false}; + uint32_t vendor_id{0}; + uint32_t product_id{0}; + uint32_t bcd_device{0}; + uint32_t interface_number{0}; + StringRef manufacturer{}; + StringRef product{}; + StringRef serial_number{}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { @@ -3477,7 +3518,7 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { #ifdef USE_ZIGBEE_PROXY class ZigbeeProxyRequest final : public ProtoDecodableMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 153; + static constexpr uint16_t MESSAGE_TYPE = 155; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("zigbee_proxy_request"); } diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 98dbae5bcc..ab354f5cca 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -143,6 +143,8 @@ template<> const char *proto_enum_to_string(enums::S return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS232"); case enums::SERIAL_PROXY_PORT_TYPE_RS485: return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS485"); + case enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL: + return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_USB_SERIAL"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -2839,6 +2841,25 @@ const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); return out.c_str(); } +const char *SerialProxyGetUsbInfoRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetUsbInfoRequest")); + 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")); + 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); + dump_field(out, ESPHOME_PSTR("vendor_id"), this->vendor_id); + dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); + dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); + dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); + 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); + return out.c_str(); +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index edf460c4b6..442652d595 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -723,6 +723,17 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif +#ifdef USE_SERIAL_PROXY + case SerialProxyGetUsbInfoRequest::MESSAGE_TYPE: { + SerialProxyGetUsbInfoRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_get_usb_info_request"), msg); +#endif + this->on_serial_proxy_get_usb_info_request(msg); + break; + } +#endif #ifdef USE_ZIGBEE_PROXY case ZigbeeProxyRequest::MESSAGE_TYPE: { ZigbeeProxyRequest msg; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index f7476b12e1..9dd2cfd0f0 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -238,6 +238,10 @@ class APIServerConnectionBase { #ifdef USE_SERIAL_PROXY void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + void on_serial_proxy_get_usb_info_request(const SerialProxyGetUsbInfoRequest &value){}; +#endif + #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index b6e780fabd..9115bf905c 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -18,9 +18,10 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_NAME +from esphome.const import CONF_ID, CONF_NAME, CONF_UART_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -34,11 +35,14 @@ SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap") api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") +# User-selectable electrical types. USB_SERIAL is deliberately absent: it is derived +# from the uart_id pointing at a usb_uart channel, never set by the user. SERIAL_PROXY_PORT_TYPES = { "TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL, "RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232, "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, } +PORT_TYPE_USB_SERIAL = SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL CONF_DTR_PIN = "dtr_pin" CONF_PORT_TYPE = "port_type" @@ -63,7 +67,7 @@ CONFIG_SCHEMA = ( { cv.GenerateID(): cv.declare_id(SerialProxy), cv.Required(CONF_NAME): cv.string_strict, - cv.Required(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True), + cv.Optional(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True), cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, } @@ -73,6 +77,26 @@ CONFIG_SCHEMA = ( ) +def _uses_usb_uart(config: ConfigType, full_config: ConfigType) -> bool: + from esphome.components.usb_uart import is_usb_uart_channel + + return is_usb_uart_channel(config[CONF_UART_ID], full_config) + + +def _final_validate(config: ConfigType) -> ConfigType: + if _uses_usb_uart(config, fv.full_config.get()): + if CONF_PORT_TYPE in config: + raise cv.Invalid( + f"{CONF_PORT_TYPE} is set automatically for USB serial ports" + ) + elif CONF_PORT_TYPE not in config: + raise cv.Invalid(f"{CONF_PORT_TYPE} is required") + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_serial_proxy_count_define() -> None: """Emit the SERIAL_PROXY_COUNT define once with the final instance count.""" @@ -87,7 +111,13 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) cg.add(cg.App.register_serial_proxy(var)) cg.add(var.set_name(config[CONF_NAME])) - cg.add(var.set_port_type(config[CONF_PORT_TYPE])) + if _uses_usb_uart(config, CORE.config): + cg.add(var.set_port_type(PORT_TYPE_USB_SERIAL)) + channel = await cg.get_variable(config[CONF_UART_ID]) + cg.add(var.set_usb_channel(channel)) + cg.add_define("USE_SERIAL_PROXY_USB_INFO") + else: + cg.add(var.set_port_type(config[CONF_PORT_TYPE])) cg.add_define("USE_SERIAL_PROXY") # Track instance count for the FINAL priority define diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 129745c1c9..6a9208c01b 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -12,6 +12,10 @@ #include "esphome/components/api/api_server.h" #endif +#ifdef USE_SERIAL_PROXY_USB_INFO +#include "esphome/components/usb_uart/usb_uart.h" +#endif + namespace esphome::serial_proxy { static const char *const TAG = "serial_proxy"; @@ -156,9 +160,10 @@ void SerialProxy::dump_config() { " RTS Pin: %s\n" " DTR Pin: %s", this->instance_index_, this->name_ != nullptr ? this->name_ : "", - this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485") - : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232") - : LOG_STR_LITERAL("TTL"), + this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485") + : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232") + : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL ? LOG_STR_LITERAL("USB_SERIAL") + : LOG_STR_LITERAL("TTL"), this->rts_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured"), this->dtr_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured")); } @@ -333,6 +338,27 @@ 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 { + if (this->usb_channel_ == nullptr) { + resp.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); +} +#endif + uint32_t SerialProxy::get_modem_pins() const { return (this->rts_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | (this->dtr_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index e3f4264cfa..07c7b643cb 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -20,6 +20,15 @@ #include "esphome/components/api/api_pb2.h" #endif +#ifdef USE_SERIAL_PROXY_USB_INFO +namespace esphome::usb_uart { +class USBUartChannel; +} // namespace esphome::usb_uart +namespace esphome::usb_host { +struct UsbDeviceInfo; +} // namespace esphome::usb_host +#endif + // Forward-declare types needed outside the USE_API guard. namespace esphome::api { class APIConnection; @@ -155,6 +164,17 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Set the DTR GPIO pin (from YAML configuration) void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; } +#ifdef USE_SERIAL_PROXY_USB_INFO + /// Attach the USB UART channel behind this port (from code generation) + 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; +#endif +#endif + #ifdef USE_SERIAL_PROXY_TAP /// Attach a traffic observer. At most one, set once at setup time. void set_tap(SerialProxyTap *tap) { this->tap_ = tap; } @@ -251,6 +271,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { #ifdef USE_SERIAL_PROXY_TAP SerialProxyTap *tap_{nullptr}; #endif + +#ifdef USE_SERIAL_PROXY_USB_INFO + /// The USB UART channel behind this port; nullptr on non-USB ports + usb_uart::USBUartChannel *usb_channel_{nullptr}; +#endif }; } // namespace esphome::serial_proxy diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 42869fb2a6..d14b44fa49 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -117,6 +117,20 @@ struct UsbEvent { // callback function type. +// USB string descriptors hold at most 126 characters; one more for the terminator +static constexpr size_t DESC_STRING_BUF_SIZE = 128; + +/// Identity of a connected USB device, copied out of the descriptors the USB host +/// stack caches for the lifetime of the connection +struct UsbDeviceInfo { + uint16_t vendor_id; + uint16_t product_id; + uint16_t bcd_device; + char manufacturer[DESC_STRING_BUF_SIZE]; + char product[DESC_STRING_BUF_SIZE]; + char serial_number[DESC_STRING_BUF_SIZE]; +}; + enum ClientState { USB_CLIENT_INIT = 0, USB_CLIENT_OPEN, @@ -144,6 +158,10 @@ class USBClient : public Component { bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, const std::vector &data = {}); + /// Copy the connected device's identity out of the cached USB descriptors. + /// Returns false when no device is connected. + bool get_device_info(UsbDeviceInfo &info) const; + // Lock-free event queue and pool for USB task to main loop communication // Must be public for access from static callbacks LockFreeQueue event_queue; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 7bc2b0a16b..7e5484abdb 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -143,10 +143,8 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc } while (next_desc != NULL); } #endif -// USB string descriptors: bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType). -// Character count = (bLength - 2) / 2, max 126 chars + null terminator. -static constexpr size_t DESC_STRING_BUF_SIZE = 128; - +// bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType), +// so character count = (bLength - 2) / 2. static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { if (desc == nullptr || desc->bLength < 2) return "(unspecified)"; @@ -162,6 +160,41 @@ static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { + buffer[0] = '\0'; + if (desc == nullptr || desc->bLength < 2) + return; + int char_count = (desc->bLength - 2) / 2; + char *p = buffer.data(); + char *end = p + buffer.size() - 1; + for (int i = 0; i != char_count && p < end; i++) { + auto c = desc->wData[i]; + if (c < 0x100) + *p++ = static_cast(c); + } + *p = '\0'; +} + +bool USBClient::get_device_info(UsbDeviceInfo &info) const { + if (this->state_ != USB_CLIENT_CONNECTED) + return false; + const usb_device_desc_t *desc; + if (usb_host_get_device_descriptor(this->device_handle_, &desc) != ESP_OK) + return false; + info.vendor_id = desc->idVendor; + info.product_id = desc->idProduct; + info.bcd_device = desc->bcdDevice; + usb_device_info_t dev_info; + if (usb_host_device_info(this->device_handle_, &dev_info) != ESP_OK) + return false; + copy_descriptor_string(dev_info.str_desc_manufacturer, info.manufacturer); + copy_descriptor_string(dev_info.str_desc_product, info.product); + copy_descriptor_string(dev_info.str_desc_serial_num, info.serial_number); + 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/__init__.py b/esphome/components/usb_uart/__init__.py index edbf75f70f..b696ca0352 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_DUMMY_RECEIVER, CONF_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.cpp_types import Component from esphome.types import ConfigType @@ -27,6 +27,16 @@ usb_uart_ns = cg.esphome_ns.namespace("usb_uart") USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component) USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent) + +def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool: + """Return True if the given ID refers to a channel of a configured usb_uart device.""" + return any( + channel[CONF_ID] == uart_id + for device in full_config.get("usb_uart") or [] + for channel in device[CONF_CHANNELS] + ) + + UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 00b34fb942..1f3c32d8cb 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -164,6 +164,9 @@ 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) + uint8_t get_index() const { return this->index_; } + 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) {} diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3f0e7e901c..452d983191 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -395,6 +395,10 @@ #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 +// USB identity on serial proxy ports needs the usb_host stack +#ifdef USE_ESP32 +#define USE_SERIAL_PROXY_USB_INFO +#endif #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7) diff --git a/tests/components/serial_proxy/test-usb.esp32-s3-idf.yaml b/tests/components/serial_proxy/test-usb.esp32-s3-idf.yaml new file mode 100644 index 0000000000..cb018f1852 --- /dev/null +++ b/tests/components/serial_proxy/test-usb.esp32-s3-idf.yaml @@ -0,0 +1,21 @@ +wifi: + ssid: MySSID + password: password1 + +api: + +usb_host: + +# port_type is omitted deliberately: a port on a USB UART channel derives USB_SERIAL +usb_uart: + - type: CDC_ACM + vid: 0x303A + pid: 0x831A + channels: + - id: usb_serial_channel + baud_rate: 460800 + +serial_proxy: + - id: serial_proxy_usb + uart_id: usb_serial_channel + name: USB Serial Port diff --git a/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml b/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml index ed9ad9f24a..e3ee3e7116 100644 --- a/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml +++ b/tests/components/zigbee_proxy/test-usb.esp32-s3-idf.yaml @@ -21,7 +21,6 @@ serial_proxy: - id: zigbee_usb_serial uart_id: zigbee_usb_channel name: Zigbee - port_type: TTL zigbee_proxy: serial_proxy_id: zigbee_usb_serial