[usb_host] Allow filtering USB devices by manufacturer and product

This commit is contained in:
puddly
2026-09-09 21:26:36 +00:00
parent 8aae67b7b2
commit 7d85efb641
5 changed files with 82 additions and 1 deletions
+13 -1
View File
@@ -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
+11
View File
@@ -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<UsbEvent, USB_EVENT_QUEUE_SIZE> 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_bitmask_t> 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_{};
};
@@ -177,6 +177,26 @@ static void copy_descriptor_string(const usb_str_desc_t *desc, std::span<char, D
*p = '\0';
}
// Descriptor strings are UTF-16, so a character above Latin-1 can never match.
static bool descriptor_string_equals(const usb_str_desc_t *desc, const char *expected) {
const int char_count = (desc == nullptr || desc->bLength < 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<char>(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
@@ -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
+17
View File
@@ -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