From 7d85efb641d7aa60ecff41a2773589c925f590c3 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:28:53 +0000 Subject: [PATCH] [usb_host] Allow filtering USB devices by manufacturer and product --- esphome/components/usb_host/__init__.py | 14 +++++++- esphome/components/usb_host/usb_host.h | 11 ++++++ .../components/usb_host/usb_host_client.cpp | 36 +++++++++++++++++++ .../usb_host/test.esp32-s3-idf.yaml | 5 +++ tests/components/usb_uart/common.yaml | 17 +++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 4abcc3a449..1263120c4e 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -26,6 +26,8 @@ USBClient = usb_host_ns.class_("USBClient", Component) DOMAIN = "usb_host" CONF_VID = "vid" CONF_PID = "pid" +CONF_MANUFACTURER = "manufacturer" +CONF_PRODUCT = "product" CONF_ENABLE_HUBS = "enable_hubs" CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests" CONF_MAX_PACKET_SIZE = "max_packet_size" @@ -47,7 +49,13 @@ def usb_device_schema( schema = schema.extend({cv.Optional(CONF_PID, default=pid): cv.hex_uint16_t}) else: schema = schema.extend({cv.Required(CONF_PID): cv.hex_uint16_t}) - return schema + + return schema.extend( + { + cv.Optional(CONF_MANUFACTURER): cv.string_strict, + cv.Optional(CONF_PRODUCT): cv.string_strict, + } + ) def _set_max_packet_size(config: dict) -> dict: @@ -91,6 +99,10 @@ CONFIG_SCHEMA = cv.All( async def register_usb_client(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], config[CONF_VID], config[CONF_PID]) await cg.register_component(var, config) + if (manufacturer := config.get(CONF_MANUFACTURER)) is not None: + cg.add(var.set_manufacturer_filter(manufacturer)) + if (product := config.get(CONF_PRODUCT)) is not None: + cg.add(var.set_product_filter(product)) return var diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index d14b44fa49..088b222c0e 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -162,6 +162,11 @@ class USBClient : public Component { /// Returns false when no device is connected. bool get_device_info(UsbDeviceInfo &info) const; + /// 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; } + // Lock-free event queue and pool for USB task to main loop communication // Must be public for access from static callbacks LockFreeQueue event_queue; @@ -179,6 +184,9 @@ class USBClient : public Component { TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe) virtual void disconnect(); virtual void on_connected() {} + + /// Whether the device's descriptor strings satisfy every filter that is set. + bool descriptor_strings_match_(const usb_device_info_t &dev_info) const; virtual void on_disconnected() { // Reset all requests to available (all bits to 0) this->trq_in_use_.store(0); @@ -199,6 +207,9 @@ 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_; + // 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_{}; }; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 7e5484abdb..76daf68b02 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -177,6 +177,26 @@ static void copy_descriptor_string(const usb_str_desc_t *desc, std::spanbLength < 2) ? 0 : (desc->bLength - 2) / 2; + for (int i = 0; i != char_count; i++) { + const uint16_t c = desc->wData[i]; + if (c >= 0x100 || expected[i] == '\0' || static_cast(c) != expected[i]) + return false; + } + return expected[char_count] == '\0'; +} + +bool USBClient::descriptor_strings_match_(const usb_device_info_t &dev_info) const { + if (this->manufacturer_filter_ != nullptr && + !descriptor_string_equals(dev_info.str_desc_manufacturer, this->manufacturer_filter_)) + return false; + if (this->product_filter_ != nullptr && !descriptor_string_equals(dev_info.str_desc_product, this->product_filter_)) + return false; + return true; +} + bool USBClient::get_device_info(UsbDeviceInfo &info) const { if (this->state_ != USB_CLIENT_CONNECTED) return false; @@ -349,6 +369,16 @@ void USBClient::handle_open_state_() { this->disconnect(); return; } + // Scoped so the buffers do not outlive this cold branch + if (!this->descriptor_strings_match_(dev_info)) { + char buf_manuf[DESC_STRING_BUF_SIZE]; + char buf_product[DESC_STRING_BUF_SIZE]; + ESP_LOGD(TAG, "Device does not match filter, closing. Manuf: %s; Prod: %s", + get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf), + get_descriptor_string(dev_info.str_desc_product, buf_product)); + this->disconnect(); + return; + } this->state_ = USB_CLIENT_CONNECTED; char buf_manuf[DESC_STRING_BUF_SIZE]; char buf_product[DESC_STRING_BUF_SIZE]; @@ -590,6 +620,12 @@ void USBClient::dump_config() { " Vendor id %04X\n" " Product id %04X", this->vid_, this->pid_); + if (this->manufacturer_filter_ != nullptr) { + ESP_LOGCONFIG(TAG, " Manufacturer %s", this->manufacturer_filter_); + } + if (this->product_filter_ != nullptr) { + ESP_LOGCONFIG(TAG, " Product %s", this->product_filter_); + } } // THREAD CONTEXT: Called from both USB task and main loop threads // - USB task: Immediately after transfer callback completes diff --git a/tests/components/usb_host/test.esp32-s3-idf.yaml b/tests/components/usb_host/test.esp32-s3-idf.yaml index 5360d1f6ff..ec96b26d57 100644 --- a/tests/components/usb_host/test.esp32-s3-idf.yaml +++ b/tests/components/usb_host/test.esp32-s3-idf.yaml @@ -4,3 +4,8 @@ usb_host: - id: device_1 vid: 0x1234 pid: 0x1234 + - id: device_2 + vid: 0x1234 + pid: 0x5678 + manufacturer: Example Corp + product: Example Widget diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 5b23f9d685..704418ff61 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -65,3 +65,20 @@ usb_uart: stop_bits: 2 data_bits: 7 parity: even + # A ZBT-2 and a ZWA-2 both enumerate as 303A:4001, so only iProduct separates them + - id: uart_9 + type: cdc_acm + vid: 0x303A + pid: 0x4001 + manufacturer: Nabu Casa + product: ZBT-2 + channels: + - id: channel_9_1 + - id: uart_10 + type: cdc_acm + vid: 0x303A + pid: 0x4001 + manufacturer: Nabu Casa + product: ZWA-2 + channels: + - id: channel_10_1