[usb_host] Unsolicited USB info notifications

[usb_host] Interface number
This commit is contained in:
puddly
2026-09-10 18:48:05 +00:00
parent 703ffbd22b
commit 90f428b0fb
16 changed files with 165 additions and 24 deletions
+9 -2
View File
@@ -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,7 +2904,7 @@ 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;
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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<uint32_t>(this->status));
+2 -2
View File
@@ -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;
#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{};
+2 -2
View File
@@ -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<enums::SerialProxyStatus>(this->status));
dump_field(out, ESPHOME_PSTR("connected"), this->connected);
+12
View File
@@ -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<int32_t> *timings) {
+4
View File
@@ -189,6 +189,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<int32_t> *timings);
#endif
@@ -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
@@ -338,26 +345,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<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) |
+14 -3
View File
@@ -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
@@ -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};
+20
View File
@@ -167,6 +167,15 @@ class USBClient : public Component {
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<typename F> void add_on_connection_callback(F &&callback) {
this->connection_callback_.add(std::forward<F>(callback));
}
// 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;
@@ -187,6 +196,13 @@ 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);
@@ -207,11 +223,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_bitmask_t> trq_in_use_;
LazyCallbackManager<void(bool)> 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:
@@ -397,6 +397,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) {
@@ -467,6 +479,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) {
@@ -475,6 +491,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
+12 -1
View File
@@ -164,9 +164,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
/// they arrive, eliminating one full main-loop-wakeup cycle of latency.
void set_rx_callback(std::function<void()> 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;
}
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) {}
@@ -252,6 +259,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<USBUartChannelBase *> channels_{};
// Config state machine
@@ -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
@@ -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
@@ -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
@@ -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