[serial_proxy] Add USB_SERIAL port type and device identity (#19308)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
This commit is contained in:
puddly
2026-10-07 19:05:07 +00:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston Keith Burzinski
parent ae6ebd2d9d
commit 1c0d406cbb
21 changed files with 558 additions and 14 deletions
+52
View File
@@ -76,6 +76,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 subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {}
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
}
@@ -233,6 +234,7 @@ enum SerialProxyPortType {
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; // since API 1.18
}
message SerialProxyInfo {
@@ -2907,6 +2909,56 @@ message SerialProxySetModeRequest {
SerialProxyMode mode = 2;
}
// Subscribe to the identity of every serial proxy port. The device answers with one
// SerialProxyIdentity per port, then sends another whenever a port's identity changes,
// for the life of the connection (since API 1.18).
message SubscribeSerialProxyIdentityRequest {
option (id) = 154;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SERIAL_PROXY";
}
// Where a port's identity comes from
enum SerialProxyIdentitySource {
SERIAL_PROXY_IDENTITY_SOURCE_NONE = 0; // The port carries no identity
SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED = 1; // Reserved for identities stated in the device configuration; not sent yet
SERIAL_PROXY_IDENTITY_SOURCE_USB = 2; // Read from the descriptors of the USB device behind the port;
// changes when a device is attached or removed
}
enum SerialProxyIdentityFlag {
SERIAL_PROXY_IDENTITY_FLAG_NONE = 0;
SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1; // The backend believes the device is present and usable on this port
SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2; // The USB host stack refused the descriptor query; the strings and
// IDs below are empty
}
// The descriptor fields of a USB device as seen by the host stack
message UsbDeviceDescriptor {
uint32 vendor_id = 1;
uint32 product_id = 2;
uint32 bcd_device = 3;
uint32 interface_number = 4; // bInterfaceNumber the host driver binds to
}
// The identity of the device behind a port. Identifies one physical device among others of
// the same kind, so a client matches on manufacturer and product regardless of source. The
// strings are empty for source NONE, and for source USB while nothing is connected
// (since API 1.18).
message SerialProxyIdentity {
option (id) = 155;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SERIAL_PROXY";
uint32 instance = 1;
SerialProxyIdentitySource source = 2;
uint32 flags = 3; // Bitmask of SerialProxyIdentityFlag
string manufacturer = 4;
string product = 5;
string serial_number = 6;
UsbDeviceDescriptor usb = 7; // Only sent for source USB
}
// ==================== BLUETOOTH CONNECTION PARAMS ====================
message BluetoothSetConnectionParamsRequest {
option (id) = 145;
+16
View File
@@ -1685,6 +1685,22 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
}
}
void APIConnection::on_subscribe_serial_proxy_identity_request() {
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
// Only USB ports change identity after this snapshot
this->flags_.serial_proxy_identity_subscription = true;
#endif
for (auto *proxy : App.get_serial_proxies()) {
proxy->send_identity(this);
}
}
void APIConnection::send_serial_proxy_identity(const SerialProxyIdentity &msg) {
if (!this->send_message(msg)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy identity");
}
}
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
+8 -2
View File
@@ -246,6 +246,9 @@ 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_subscribe_serial_proxy_identity_request();
/// Send a port identity to this client
void send_serial_proxy_identity(const SerialProxyIdentity &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);
@@ -760,9 +763,12 @@ class APIConnection final : public APIServerConnectionBase {
#ifdef HAS_PROTO_MESSAGE_DUMP
uint8_t log_only_mode : 1;
#endif
} flags_{}; // 2 bytes total
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
uint8_t serial_proxy_identity_subscription : 1;
#endif
} flags_{}; // 2 bytes; 3 with HAS_PROTO_MESSAGE_DUMP + USE_API_OUTGOING_CONNECTION + USE_SERIAL_PROXY_USB_IDENTITY
// 2-byte type immediately after flags_ (no padding between them)
// 2-byte type immediately after flags_ (one padding byte when flags_ is 3 bytes)
uint16_t batch_message_type_{0}; // Current message type during batch encoding
// 1-byte types to fill remaining space before next 4-byte boundary
// Client API versions are clamped to 255 on receive (see send_hello_response_)
+42
View File
@@ -4199,6 +4199,48 @@ void SerialProxySetModeRequest::decode_field(void *self, uint32_t tag, const uin
break;
}
}
uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const UsbDeviceDescriptor *>(self);
uint8_t *__restrict__ pos = buffer.get_pos();
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.vendor_id);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number);
return pos;
}
uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const UsbDeviceDescriptor *>(self);
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, msg.vendor_id);
size += ProtoSize::calc_uint32(1, msg.product_id);
size += ProtoSize::calc_uint32(1, msg.bcd_device);
size += ProtoSize::calc_uint32(1, msg.interface_number);
return size;
}
uint8_t *SerialProxyIdentity::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const SerialProxyIdentity *>(self);
uint8_t *__restrict__ pos = buffer.get_pos();
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(msg.source));
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.manufacturer);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.product);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.serial_number);
pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 7, msg.usb);
return pos;
}
uint32_t SerialProxyIdentity::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const SerialProxyIdentity *>(self);
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, msg.instance);
size += msg.source ? 2 : 0;
size += ProtoSize::calc_uint32(1, msg.flags);
size += ProtoSize::calc_length(1, msg.manufacturer.size());
size += ProtoSize::calc_length(1, msg.product.size());
size += ProtoSize::calc_length(1, msg.serial_number.size());
size += ProtoSize::calc_message(1, msg.usb.calculate_size());
return size;
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void BluetoothSetConnectionParamsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data,
+55
View File
@@ -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,
@@ -371,7 +372,17 @@ enum SerialProxyMode : uint32_t {
SERIAL_PROXY_MODE_RAW = 0,
SERIAL_PROXY_MODE_PROTOCOL = 1,
};
enum SerialProxyIdentitySource : uint32_t {
SERIAL_PROXY_IDENTITY_SOURCE_NONE = 0,
SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED = 1,
SERIAL_PROXY_IDENTITY_SOURCE_USB = 2,
};
#endif
enum SerialProxyIdentityFlag : uint32_t {
SERIAL_PROXY_IDENTITY_FLAG_NONE = 0,
SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1,
SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2,
};
} // namespace enums
@@ -3973,6 +3984,50 @@ class SerialProxySetModeRequest final : public ProtoDecodableMessage {
protected:
static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar);
};
class UsbDeviceDescriptor final : public ProtoMessage {
public:
uint32_t vendor_id{0};
uint32_t product_id{0};
uint32_t bcd_device{0};
uint32_t interface_number{0};
static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG);
}
static uint32_t calc_size_msg(const void *self);
uint32_t calculate_size() const { return calc_size_msg(this); }
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
class SerialProxyIdentity final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 155;
static constexpr uint8_t ESTIMATED_SIZE = 54;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_identity"); }
#endif
uint32_t instance{0};
enums::SerialProxyIdentitySource source{};
uint32_t flags{0};
StringRef manufacturer{nullptr, 0}; // null until set, encode only
StringRef product{nullptr, 0}; // null until set, encode only
StringRef serial_number{nullptr, 0}; // null until set, encode only
UsbDeviceDescriptor usb{};
static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG);
}
static uint32_t calc_size_msg(const void *self);
uint32_t calculate_size() const { return calc_size_msg(this); }
#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 {
+47
View File
@@ -143,6 +143,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(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");
}
@@ -890,7 +892,31 @@ template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::Seria
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyIdentitySource>(enums::SerialProxyIdentitySource value) {
switch (value) {
case enums::SERIAL_PROXY_IDENTITY_SOURCE_NONE:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_NONE");
case enums::SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED");
case enums::SERIAL_PROXY_IDENTITY_SOURCE_USB:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_USB");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
template<> const char *proto_enum_to_string<enums::SerialProxyIdentityFlag>(enums::SerialProxyIdentityFlag value) {
switch (value) {
case enums::SERIAL_PROXY_IDENTITY_FLAG_NONE:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_NONE");
case enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_CONNECTED");
case enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_ERROR");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
const char *HelloRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("HelloRequest"));
@@ -2841,6 +2867,27 @@ const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
return out.c_str();
}
const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("UsbDeviceDescriptor"));
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);
return out.c_str();
}
const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyIdentity"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("source"), static_cast<enums::SerialProxyIdentitySource>(this->source));
dump_field(out, ESPHOME_PSTR("flags"), this->flags);
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);
out.append(2, ' ').append_p(ESPHOME_PSTR("usb")).append(": ");
this->usb.dump_to(out);
out.append("\n");
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
@@ -722,6 +722,15 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
this->on_serial_proxy_set_mode_request(msg);
break;
}
#endif
#ifdef USE_SERIAL_PROXY
case 154 /* SubscribeSerialProxyIdentityRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_subscribe_serial_proxy_identity_request"));
#endif
this->on_subscribe_serial_proxy_identity_request();
break;
}
#endif
default:
break;
+4
View File
@@ -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_subscribe_serial_proxy_identity_request(){};
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif
+9
View File
@@ -504,6 +504,15 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
void APIServer::send_serial_proxy_identity(const SerialProxyIdentity &msg) {
for (auto &c : this->active_clients()) {
if (c->flags_.serial_proxy_identity_subscription)
c->send_serial_proxy_identity(msg);
}
}
#endif
#ifdef USE_IR_RF
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
@@ -203,6 +203,10 @@ class APIServer final : public Component
#ifdef USE_ZWAVE_PROXY
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
/// Tell every subscribed client that a serial proxy port's identity changed
void send_serial_proxy_identity(const SerialProxyIdentity &msg);
#endif
#ifdef USE_IR_RF
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
#endif
+22 -1
View File
@@ -17,10 +17,12 @@ from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import uart
from esphome.components.usb_uart import is_usb_uart_channel
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"]
@@ -38,6 +40,7 @@ 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,
"USB_SERIAL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL,
}
CONF_DTR_PIN = "dtr_pin"
@@ -73,6 +76,18 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
is_usb = is_usb_uart_channel(config[CONF_UART_ID], fv.full_config.get())
if config[CONF_PORT_TYPE] == "USB_SERIAL" and not is_usb:
raise cv.Invalid(
f"{CONF_PORT_TYPE} USB_SERIAL requires {CONF_UART_ID} to be a usb_uart channel"
)
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."""
@@ -88,6 +103,12 @@ async def to_code(config: ConfigType) -> None:
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]))
# port_type names the electrical interface (a USB RS485 adapter is RS485), so every
# usb_uart channel reports USB identity whatever port type it declares
if is_usb_uart_channel(config[CONF_UART_ID], CORE.config):
channel = await cg.get_variable(config[CONF_UART_ID])
cg.add(var.set_usb_channel(channel))
cg.add_define("USE_SERIAL_PROXY_USB_IDENTITY")
cg.add_define("USE_SERIAL_PROXY")
# Track instance count for the FINAL priority define
@@ -14,6 +14,10 @@
#include "esphome/components/api/api_server.h"
#endif
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
#include "esphome/components/usb_uart/usb_uart.h"
#endif
namespace esphome::serial_proxy {
static const char *const TAG = "serial_proxy";
@@ -35,6 +39,12 @@ 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_IDENTITY
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
@@ -161,9 +171,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"));
}
@@ -373,6 +384,60 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
#ifdef USE_API
void SerialProxy::send_identity(api::APIConnection *api_connection) {
IdentityScratch scratch;
api::SerialProxyIdentity msg{};
this->fill_identity_(scratch, msg);
api_connection->send_serial_proxy_identity(msg);
}
void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api::SerialProxyIdentity &msg) const {
msg.instance = this->instance_index_;
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
// The define is global, so a hardware UART port in the same config also gets here
if (this->usb_channel_ != nullptr) {
msg.source = api::enums::SERIAL_PROXY_IDENTITY_SOURCE_USB;
// Covers a removed device, a channel the device has no CDC function for and a failed channel setup
if (!this->usb_channel_->is_connected()) {
return;
}
auto *client = this->usb_channel_->get_parent();
msg.flags = api::enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED;
if (!client->get_device_info(scratch)) {
msg.flags |= api::enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR;
return;
}
msg.usb.vendor_id = scratch.vendor_id;
msg.usb.product_id = scratch.product_id;
msg.usb.bcd_device = scratch.bcd_device;
msg.usb.interface_number = this->usb_channel_->get_interface_number();
msg.manufacturer = StringRef(scratch.manufacturer);
msg.product = StringRef(scratch.product);
msg.serial_number = StringRef(scratch.serial_number);
return;
}
#endif
// Zero-initialized message: source NONE, no flags
}
#endif
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
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_API
if (api::global_api_server == nullptr) {
return;
}
IdentityScratch scratch;
api::SerialProxyIdentity msg{};
this->fill_identity_(scratch, msg);
api::global_api_server->send_serial_proxy_identity(msg);
#endif
}
#endif
uint32_t SerialProxy::get_modem_pins() const {
return (this->rts_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) |
(this->dtr_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u);
@@ -20,6 +20,15 @@
#include "esphome/components/api/api_pb2.h"
#endif
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
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;
@@ -160,6 +169,16 @@ 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_IDENTITY
/// Attach the USB UART channel behind this port (from code generation)
void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; }
#endif
#ifdef USE_API
/// Send this port's identity to one client
void send_identity(api::APIConnection *api_connection);
#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; }
@@ -226,6 +245,23 @@ class SerialProxy final : public uart::UARTDevice, public Component {
bool tap_observing_() const;
#endif
#ifdef USE_API
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
using IdentityScratch = usb_host::UsbDeviceInfo;
#else
struct IdentityScratch {};
#endif
/// Fill an identity message for this port. The message's strings are views into scratch,
/// so it must outlive the send.
void fill_identity_(IdentityScratch &scratch, api::SerialProxyIdentity &msg) const;
#endif
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
/// The USB device behind this port was attached or removed; report the port's new
/// identity to every subscribed API client
void on_usb_connection_changed_(bool connected);
#endif
/// Instance index for identifying this proxy in API messages
uint32_t instance_index_{0};
@@ -268,6 +304,11 @@ class SerialProxy final : public uart::UARTDevice, public Component {
#ifdef USE_SERIAL_PROXY_TAP
SerialProxyTap *tap_{nullptr};
#endif
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
/// The USB UART channel behind this port; nullptr on non-USB ports
usb_uart::USBUartChannel *usb_channel_{nullptr};
#endif
};
} // namespace esphome::serial_proxy
+49
View File
@@ -5,6 +5,7 @@
defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4)
#include "esphome/core/defines.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <vector>
#include "usb/usb_host.h"
#include <freertos/FreeRTOS.h>
@@ -12,6 +13,7 @@
#include "esphome/core/lock_free_queue.h"
#include "esphome/core/event_pool.h"
#include <atomic>
#include <span>
namespace esphome::usb_host {
@@ -117,6 +119,25 @@ 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];
};
/// Copy a USB string descriptor into a NUL-terminated buffer. A missing descriptor copies as
/// an empty string. Returns false when a descriptor contains non-ASCII characters,
/// UTF-16 to UTF-8 conversion is not currently implemented.
bool copy_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer);
enum ClientState {
USB_CLIENT_INIT = 0,
USB_CLIENT_OPEN,
@@ -146,6 +167,23 @@ class USBClient : public Component {
void set_manufacturer_filter(const char16_t *manufacturer) { this->manufacturer_filter_ = manufacturer; }
void set_product_filter(const char16_t *product) { this->product_filter_ = product; }
/// Whether a device has been opened and its setup by the subclass has finished
bool is_connected() const { return this->connection_reported_; }
/// Copy the connected device's identity out of the cached USB descriptors.
/// Returns false when no device is connected or the host stack refused the query.
bool get_device_info(UsbDeviceInfo &info) const;
/// Register a callback for the device this client claims being connected (true) or
/// removed (false). Fires only for a device that was fully opened, so a device another
/// client claims is never reported. Called from the main loop: connected once the device
/// has been enumerated and the subclass has finished its setup of it (whether or not that
/// setup succeeded), removed after on_disconnected() has run. This tracks the device's
/// presence, not whether a given channel is usable.
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;
@@ -163,6 +201,13 @@ 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 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);
@@ -179,6 +224,7 @@ class USBClient : public Component {
usb_device_handle_t device_handle_{};
int device_addr_{-1};
int state_{USB_CLIENT_INIT};
LazyCallbackManager<void(bool)> connection_callback_;
// Lock-free pool management using atomic bitmask (no dynamic allocation)
// 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)
@@ -187,6 +233,9 @@ class USBClient : public Component {
const char16_t *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:
@@ -145,9 +145,27 @@ 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.
bool copy_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
buffer[0] = '\0';
if (desc == nullptr || desc->bLength < 2)
return true;
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];
// TODO: encode non-ASCII code units as UTF-8 if a device with such descriptors turns up
if (c >= 0x80) {
buffer[0] = '\0';
return false;
}
*p++ = static_cast<char>(c);
}
*p = '\0';
return true;
}
// Folds UTF-16 to Latin-1 for logging, dropping anything that does not fit
template<typename T>
@@ -183,6 +201,36 @@ static bool descriptor_string_equals(const usb_str_desc_t *desc, const char16_t
return expected[char_count] == u'\0';
}
bool USBClient::get_device_info(UsbDeviceInfo &info) const {
if (!this->is_connected())
return false;
const usb_device_desc_t *desc;
esp_err_t err = usb_host_get_device_descriptor(this->device_handle_, &desc);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device descriptor query failed: %s", esp_err_to_name(err));
return false;
}
info.vendor_id = desc->idVendor;
info.product_id = desc->idProduct;
info.bcd_device = desc->bcdDevice;
usb_device_info_t dev_info;
err = usb_host_device_info(this->device_handle_, &dev_info);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device info query failed: %s", esp_err_to_name(err));
return false;
}
if (!copy_descriptor_string(dev_info.str_desc_manufacturer, info.manufacturer)) {
ESP_LOGW(TAG, "Manufacturer string descriptor is not ASCII");
}
if (!copy_descriptor_string(dev_info.str_desc_product, info.product)) {
ESP_LOGW(TAG, "Product string descriptor is not ASCII");
}
if (!copy_descriptor_string(dev_info.str_desc_serial_num, info.serial_number)) {
ESP_LOGW(TAG, "Serial number string descriptor is not ASCII");
}
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<USBClient *>(ptr);
@@ -367,6 +415,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) {
@@ -437,6 +497,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) {
@@ -445,6 +509,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
+10 -1
View File
@@ -18,7 +18,7 @@ from esphome.const import (
CONF_ID,
CONF_TYPE,
)
from esphome.core import CORE
from esphome.core import CORE, ID
from esphome.cpp_types import Component
from esphome.types import ConfigType
@@ -29,6 +29,15 @@ 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 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,
+2 -2
View File
@@ -21,8 +21,6 @@ static optional<CdcEps> get_cdc(const usb_config_desc_t *config_desc, uint8_t in
int conf_offset, ep_offset;
// look for an interface with an interrupt endpoint (notify), and one with two bulk endpoints (data in/out)
CdcEps eps{};
eps.bulk_interface_number = 0xFF;
eps.interrupt_interface_number = 0xFF;
for (;;) {
const auto *intf_desc = usb_parse_interface_descriptor(config_desc, intf_idx++, 0, &conf_offset);
if (!intf_desc) {
@@ -664,6 +662,8 @@ bool USBUartComponent::run_config_machine_() {
this->cfg_single_ = nullptr;
} else if (++this->cfg_channel_idx_ >= this->channels_.size()) {
this->cfg_active_ = false;
// Init is done and the line settings are on the wire: now the device is ready to use
this->report_connected_();
}
// If the machine just went idle and a reload was requested while it was busy, start it now.
+14 -2
View File
@@ -33,10 +33,11 @@ struct CdcEps {
const usb_ep_desc_t *notify_ep;
const usb_ep_desc_t *in_ep;
const usb_ep_desc_t *out_ep;
uint8_t bulk_interface_number;
// 0xFF marks a channel that was never matched to a CDC function on the device
uint8_t bulk_interface_number{0xFF};
// Also the wIndex target for CDC class requests (SET_LINE_CODING etc.), so it
// must remain valid even when the interface itself is not claimed.
uint8_t interrupt_interface_number;
uint8_t interrupt_interface_number{0xFF};
bool interrupt_interface_claimed{false};
};
@@ -182,6 +183,13 @@ 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); }
/// 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) {}
@@ -267,6 +275,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
+5
View File
@@ -467,6 +467,11 @@
#define USE_USB_UART_CP210X
#define USE_USB_UART_FT23XX
#define USE_USB_UART_PL2303
// USB identity on serial proxy ports needs the usb_host stack
#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \
defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4)
#define USE_SERIAL_PROXY_USB_IDENTITY
#endif
#ifdef USE_ARDUINO
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7)
@@ -49,6 +49,7 @@ class SerialProxy {
uint32_t get_modem_pins() const { return 0; }
uint32_t get_configured_modem_pins() const { return 0; }
SerialProxyResult flush_port(api::APIConnection *api_connection) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; }
void send_identity(api::APIConnection *api_connection) {}
protected:
uint32_t instance_index_{0};
@@ -0,0 +1,30 @@
# A hardware UART port alongside the USB one, so the USB identity define is exercised on a
# port that has no USB channel
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
wifi:
ssid: MySSID
password: password1
api:
usb_host:
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
port_type: USB_SERIAL
- id: serial_proxy_hw
uart_id: uart_bus
name: Hardware Serial Port
port_type: TTL