diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 9d79b0ec23..6de93d6ecb 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,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; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index a132bb6f72..ac26a90c25 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..13fa91cdbd 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)); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7fa1560a02..4a0b7db55b 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; #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{}; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index b264c917ac..ede3f2350f 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); diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 43d35363d3..01c811156f 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 072a583901..a58506b4c8 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -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 *timings); #endif diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 6a9208c01b..61263287c4 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 @@ -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(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..8ebb9ea94f 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 @@ -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/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 088b222c0e..4d1e188928 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -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 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,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_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 58914ced6e..04b2b864e9 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -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 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 357e41c639..139315841e 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -164,9 +164,16 @@ 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; + } + 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 channels_{}; // Config state machine 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