From 587ea238646fcd14bfc804ccb2ed7ea0518603dc Mon Sep 17 00:00:00 2001 From: kbx81 Date: Wed, 11 Feb 2026 19:04:42 -0600 Subject: [PATCH] [serial_proxy] New component --- esphome/components/api/api.proto | 93 ++++++++++++ esphome/components/api/api_connection.cpp | 63 ++++++++ esphome/components/api/api_connection.h | 9 ++ esphome/components/api/api_pb2.cpp | 109 ++++++++++++++ esphome/components/api/api_pb2.h | 139 +++++++++++++++++- esphome/components/api/api_pb2_dump.cpp | 65 ++++++++ esphome/components/api/api_pb2_service.cpp | 55 +++++++ esphome/components/api/api_pb2_service.h | 17 +++ esphome/components/api/api_server.cpp | 11 ++ esphome/components/api/api_server.h | 4 + esphome/components/serial_proxy/__init__.py | 62 ++++++++ .../components/serial_proxy/serial_proxy.cpp | 131 +++++++++++++++++ .../components/serial_proxy/serial_proxy.h | 80 ++++++++++ esphome/core/application.h | 17 +++ esphome/core/defines.h | 1 + tests/components/serial_proxy/common.yaml | 8 + .../serial_proxy/test.esp32-idf.yaml | 8 + .../serial_proxy/test.esp8266-ard.yaml | 8 + .../serial_proxy/test.rp2040-ard.yaml | 8 + 19 files changed, 887 insertions(+), 1 deletion(-) create mode 100644 esphome/components/serial_proxy/__init__.py create mode 100644 esphome/components/serial_proxy/serial_proxy.cpp create mode 100644 esphome/components/serial_proxy/serial_proxy.h create mode 100644 tests/components/serial_proxy/common.yaml create mode 100644 tests/components/serial_proxy/test.esp32-idf.yaml create mode 100644 tests/components/serial_proxy/test.esp8266-ard.yaml create mode 100644 tests/components/serial_proxy/test.rp2040-ard.yaml diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 18dac6a2d1..240a9228d0 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -69,6 +69,12 @@ service APIConnection { rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {} rpc infrared_rf_transmit_raw_timings(InfraredRFTransmitRawTimingsRequest) returns (void) {} + + rpc serial_proxy_configure(SerialProxyConfigureRequest) returns (void) {} + 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 serial_proxy_flush(SerialProxyFlushRequest) returns (void) {} } @@ -260,6 +266,9 @@ message DeviceInfoResponse { // Indicates if Z-Wave proxy support is available and features supported uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"]; uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + + // Number of serial proxy instances available on the device + uint32 serial_proxy_count = 25 [(field_ifdef) = "USE_SERIAL_PROXY"]; } message ListEntitiesRequest { @@ -2488,3 +2497,87 @@ message InfraredRFReceiveEvent { fixed32 key = 2; // Key identifying the receiver instance repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods } + +// ==================== SERIAL PROXY ==================== + +enum SerialProxyParity { + SERIAL_PROXY_PARITY_NONE = 0; + SERIAL_PROXY_PARITY_EVEN = 1; + SERIAL_PROXY_PARITY_ODD = 2; +} + +// Configure UART parameters for a serial proxy instance +message SerialProxyConfigureRequest { + option (id) = 138; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; // Instance index (0-based) + uint32 baudrate = 2; // Baud rate in bits per second + bool flow_control = 3; // Enable hardware flow control + SerialProxyParity parity = 4; // Parity setting + uint32 stop_bits = 5; // Number of stop bits (1 or 2) + uint32 data_size = 6; // Number of data bits (5-8) +} + +// Data received from a serial device, forwarded to clients +message SerialProxyDataReceived { + option (id) = 139; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SERIAL_PROXY"; + option (no_delay) = true; + + uint32 instance = 1; // Instance index (0-based) + bytes data = 2; // Raw data received from the serial device +} + +// Write data to a serial device +message SerialProxyWriteRequest { + option (id) = 140; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + option (no_delay) = true; + + uint32 instance = 1; // Instance index (0-based) + bytes data = 2; // Raw data to write to the serial device +} + +// Set modem control pin states (RTS and DTR) +message SerialProxySetModemPinsRequest { + option (id) = 141; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; // Instance index (0-based) + bool rts = 2; // Desired RTS pin state + bool dtr = 3; // Desired DTR pin state +} + +// Request current modem control pin states +message SerialProxyGetModemPinsRequest { + option (id) = 142; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; // Instance index (0-based) +} + +// Response with current modem control pin states +message SerialProxyGetModemPinsResponse { + option (id) = 143; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; // Instance index (0-based) + bool rts = 2; // Current RTS pin state + bool dtr = 3; // Current DTR pin state +} + +// Flush the serial port (block until all TX data is sent) +message SerialProxyFlushRequest { + option (id) = 144; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; // Instance index (0-based) +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4bc3c9b307..ad3aabe141 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1413,6 +1413,66 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent } #endif +#ifdef USE_SERIAL_PROXY +void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %u out of range (max %u)", msg.instance, + static_cast(proxies.size())); + return; + } + proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast(msg.parity), msg.stop_bits, + msg.data_size); +} + +void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + return; + } + proxies[msg.instance]->write(msg.data, msg.data_len); +} + +void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + return; + } + proxies[msg.instance]->set_modem_pins(msg.rts, msg.dtr); +} + +void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + return; + } + bool rts, dtr; + proxies[msg.instance]->get_modem_pins(rts, dtr); + + SerialProxyGetModemPinsResponse resp{}; + resp.instance = msg.instance; + resp.rts = rts; + resp.dtr = dtr; + this->send_message(resp, SerialProxyGetModemPinsResponse::MESSAGE_TYPE); +} + +void APIConnection::on_serial_proxy_flush_request(const SerialProxyFlushRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + return; + } + proxies[msg.instance]->flush_port(); +} + +void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { + this->send_message(msg, SerialProxyDataReceived::MESSAGE_TYPE); +} +#endif + #ifdef USE_INFRARED uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *infrared = static_cast(entity); @@ -1627,6 +1687,9 @@ bool APIConnection::send_device_info_response_() { resp.zwave_proxy_feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); resp.zwave_home_id = zwave_proxy::global_zwave_proxy->get_home_id(); #endif +#ifdef USE_SERIAL_PROXY + resp.serial_proxy_count = App.get_serial_proxies().size(); +#endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index d3d09a01c8..c0a0c9b860 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -182,6 +182,15 @@ class APIConnection final : public APIServerConnectionBase { void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); #endif +#ifdef USE_SERIAL_PROXY + void on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) override; + void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) override; + void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) override; + void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) override; + void on_serial_proxy_flush_request(const SerialProxyFlushRequest &msg) override; + void send_serial_proxy_data(const SerialProxyDataReceived &msg); +#endif + #ifdef USE_EVENT void send_event(event::Event *event); #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 743f51dac7..019adda2f3 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -119,6 +119,9 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { #ifdef USE_ZWAVE_PROXY buffer.encode_uint32(24, this->zwave_home_id); #endif +#ifdef USE_SERIAL_PROXY + buffer.encode_uint32(25, this->serial_proxy_count); +#endif } void DeviceInfoResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name.size()); @@ -174,6 +177,9 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ZWAVE_PROXY size.add_uint32(2, this->zwave_home_id); #endif +#ifdef USE_SERIAL_PROXY + size.add_uint32(2, this->serial_proxy_count); +#endif } #ifdef USE_BINARY_SENSOR void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { @@ -3440,5 +3446,108 @@ void InfraredRFReceiveEvent::calculate_size(ProtoSize &size) const { } } #endif +#ifdef USE_SERIAL_PROXY +bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->instance = value.as_uint32(); + break; + case 2: + this->baudrate = value.as_uint32(); + break; + case 3: + this->flow_control = value.as_bool(); + break; + case 4: + this->parity = static_cast(value.as_uint32()); + break; + case 5: + this->stop_bits = value.as_uint32(); + break; + case 6: + this->data_size = value.as_uint32(); + break; + default: + return false; + } + return true; +} +void SerialProxyDataReceived::encode(ProtoWriteBuffer buffer) const { + buffer.encode_uint32(1, this->instance); + buffer.encode_bytes(2, this->data_ptr_, this->data_len_); +} +void SerialProxyDataReceived::calculate_size(ProtoSize &size) const { + size.add_uint32(1, this->instance); + size.add_length(1, this->data_len_); +} +bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->instance = value.as_uint32(); + break; + default: + return false; + } + return true; +} +bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 2: { + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; + } + return true; +} +bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->instance = value.as_uint32(); + break; + case 2: + this->rts = value.as_bool(); + break; + case 3: + this->dtr = value.as_bool(); + break; + default: + return false; + } + return true; +} +bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->instance = value.as_uint32(); + break; + default: + return false; + } + return true; +} +void SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_uint32(1, this->instance); + buffer.encode_bool(2, this->rts); + buffer.encode_bool(3, this->dtr); +} +void SerialProxyGetModemPinsResponse::calculate_size(ProtoSize &size) const { + size.add_uint32(1, this->instance); + size.add_bool(1, this->rts); + size.add_bool(1, this->dtr); +} +bool SerialProxyFlushRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->instance = value.as_uint32(); + break; + default: + return false; + } + return true; +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d001f869c5..450d8f6a8a 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -311,6 +311,13 @@ enum ZWaveProxyRequestType : uint32_t { ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2, }; #endif +#ifdef USE_SERIAL_PROXY +enum SerialProxyParity : uint32_t { + SERIAL_PROXY_PARITY_NONE = 0, + SERIAL_PROXY_PARITY_EVEN = 1, + SERIAL_PROXY_PARITY_ODD = 2, +}; +#endif } // namespace enums @@ -474,7 +481,7 @@ class DeviceInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint8_t ESTIMATED_SIZE = 255; + static constexpr uint16_t ESTIMATED_SIZE = 260; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "device_info_response"; } #endif @@ -526,6 +533,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_ZWAVE_PROXY uint32_t zwave_home_id{0}; +#endif +#ifdef USE_SERIAL_PROXY + uint32_t serial_proxy_count{0}; #endif void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; @@ -3025,5 +3035,132 @@ class InfraredRFReceiveEvent final : public ProtoMessage { protected: }; #endif +#ifdef USE_SERIAL_PROXY +class SerialProxyConfigureRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 138; + static constexpr uint8_t ESTIMATED_SIZE = 20; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "serial_proxy_configure_request"; } +#endif + uint32_t instance{0}; + uint32_t baudrate{0}; + bool flow_control{false}; + enums::SerialProxyParity parity{}; + uint32_t stop_bits{0}; + uint32_t data_size{0}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class SerialProxyDataReceived final : public ProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 139; + static constexpr uint8_t ESTIMATED_SIZE = 23; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "serial_proxy_data_received"; } +#endif + uint32_t instance{0}; + const uint8_t *data_ptr_{nullptr}; + size_t data_len_{0}; + void set_data(const uint8_t *data, size_t len) { + this->data_ptr_ = data; + this->data_len_ = len; + } + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +class SerialProxyWriteRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 140; + static constexpr uint8_t ESTIMATED_SIZE = 23; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "serial_proxy_write_request"; } +#endif + uint32_t instance{0}; + const uint8_t *data{nullptr}; + uint16_t data_len{0}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 141; + static constexpr uint8_t ESTIMATED_SIZE = 8; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "serial_proxy_set_modem_pins_request"; } +#endif + uint32_t instance{0}; + bool rts{false}; + bool dtr{false}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 142; + static constexpr uint8_t ESTIMATED_SIZE = 4; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "serial_proxy_get_modem_pins_request"; } +#endif + uint32_t instance{0}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class SerialProxyGetModemPinsResponse final : public ProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 143; + static constexpr uint8_t ESTIMATED_SIZE = 8; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "serial_proxy_get_modem_pins_response"; } +#endif + uint32_t instance{0}; + bool rts{false}; + bool dtr{false}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +class SerialProxyFlushRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 144; + static constexpr uint8_t ESTIMATED_SIZE = 4; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "serial_proxy_flush_request"; } +#endif + uint32_t instance{0}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 73690610ed..86bff79d4a 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -736,6 +736,20 @@ template<> const char *proto_enum_to_string(enums: } } #endif +#ifdef USE_SERIAL_PROXY +template<> const char *proto_enum_to_string(enums::SerialProxyParity value) { + switch (value) { + case enums::SERIAL_PROXY_PARITY_NONE: + return "SERIAL_PROXY_PARITY_NONE"; + case enums::SERIAL_PROXY_PARITY_EVEN: + return "SERIAL_PROXY_PARITY_EVEN"; + case enums::SERIAL_PROXY_PARITY_ODD: + return "SERIAL_PROXY_PARITY_ODD"; + default: + return "UNKNOWN"; + } +} +#endif const char *HelloRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "HelloRequest"); @@ -845,6 +859,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif #ifdef USE_ZWAVE_PROXY dump_field(out, "zwave_home_id", this->zwave_home_id); +#endif +#ifdef USE_SERIAL_PROXY + dump_field(out, "serial_proxy_count", this->serial_proxy_count); #endif return out.c_str(); } @@ -2469,6 +2486,54 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_SERIAL_PROXY +const char *SerialProxyConfigureRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "SerialProxyConfigureRequest"); + dump_field(out, "instance", this->instance); + dump_field(out, "baudrate", this->baudrate); + dump_field(out, "flow_control", this->flow_control); + dump_field(out, "parity", static_cast(this->parity)); + dump_field(out, "stop_bits", this->stop_bits); + dump_field(out, "data_size", this->data_size); + return out.c_str(); +} +const char *SerialProxyDataReceived::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "SerialProxyDataReceived"); + dump_field(out, "instance", this->instance); + dump_bytes_field(out, "data", this->data_ptr_, this->data_len_); + return out.c_str(); +} +const char *SerialProxyWriteRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "SerialProxyWriteRequest"); + dump_field(out, "instance", this->instance); + dump_bytes_field(out, "data", this->data, this->data_len); + return out.c_str(); +} +const char *SerialProxySetModemPinsRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "SerialProxySetModemPinsRequest"); + dump_field(out, "instance", this->instance); + dump_field(out, "rts", this->rts); + dump_field(out, "dtr", this->dtr); + return out.c_str(); +} +const char *SerialProxyGetModemPinsRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "SerialProxyGetModemPinsRequest"); + dump_field(out, "instance", this->instance); + return out.c_str(); +} +const char *SerialProxyGetModemPinsResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "SerialProxyGetModemPinsResponse"); + dump_field(out, "instance", this->instance); + dump_field(out, "rts", this->rts); + dump_field(out, "dtr", this->dtr); + return out.c_str(); +} +const char *SerialProxyFlushRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "SerialProxyFlushRequest"); + dump_field(out, "instance", this->instance); + return out.c_str(); +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index f9151ae3b4..56d9ce5e85 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -634,6 +634,61 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_infrared_rf_transmit_raw_timings_request(msg); break; } +#endif +#ifdef USE_SERIAL_PROXY + case SerialProxyConfigureRequest::MESSAGE_TYPE: { + SerialProxyConfigureRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_configure_request"), msg); +#endif + this->on_serial_proxy_configure_request(msg); + break; + } +#endif +#ifdef USE_SERIAL_PROXY + case SerialProxyWriteRequest::MESSAGE_TYPE: { + SerialProxyWriteRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_write_request"), msg); +#endif + this->on_serial_proxy_write_request(msg); + break; + } +#endif +#ifdef USE_SERIAL_PROXY + case SerialProxySetModemPinsRequest::MESSAGE_TYPE: { + SerialProxySetModemPinsRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_set_modem_pins_request"), msg); +#endif + this->on_serial_proxy_set_modem_pins_request(msg); + break; + } +#endif +#ifdef USE_SERIAL_PROXY + case SerialProxyGetModemPinsRequest::MESSAGE_TYPE: { + SerialProxyGetModemPinsRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_get_modem_pins_request"), msg); +#endif + this->on_serial_proxy_get_modem_pins_request(msg); + break; + } +#endif +#ifdef USE_SERIAL_PROXY + case SerialProxyFlushRequest::MESSAGE_TYPE: { + SerialProxyFlushRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_flush_request"), msg); +#endif + this->on_serial_proxy_flush_request(msg); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 1441507406..8db2e3a08a 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -224,6 +224,23 @@ class APIServerConnectionBase : public ProtoService { virtual void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + virtual void on_serial_proxy_configure_request(const SerialProxyConfigureRequest &value){}; +#endif + +#ifdef USE_SERIAL_PROXY + virtual void on_serial_proxy_write_request(const SerialProxyWriteRequest &value){}; +#endif +#ifdef USE_SERIAL_PROXY + virtual void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &value){}; +#endif +#ifdef USE_SERIAL_PROXY + virtual void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &value){}; +#endif + +#ifdef USE_SERIAL_PROXY + virtual void on_serial_proxy_flush_request(const SerialProxyFlushRequest &value){}; +#endif protected: void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 53b41a5c14..59c57f1c52 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -370,6 +370,17 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ } #endif +#ifdef USE_SERIAL_PROXY +void APIServer::send_serial_proxy_data(uint32_t instance, const uint8_t *data, size_t len) { + SerialProxyDataReceived msg{}; + msg.instance = instance; + msg.set_data(data, len); + + for (auto &c : this->clients_) + c->send_serial_proxy_data(msg); +} +#endif + #ifdef USE_ALARM_CONTROL_PANEL API_DISPATCH_UPDATE(alarm_control_panel::AlarmControlPanel, alarm_control_panel) #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 6ab3cdc576..69cceefa3e 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -189,6 +189,10 @@ class APIServer : public Component, void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif +#ifdef USE_SERIAL_PROXY + void send_serial_proxy_data(uint32_t instance, const uint8_t *data, size_t len); +#endif + bool is_connected(bool state_subscription_only = false) const; #ifdef USE_API_HOMEASSISTANT_STATES diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py new file mode 100644 index 0000000000..b5d2104a1f --- /dev/null +++ b/esphome/components/serial_proxy/__init__.py @@ -0,0 +1,62 @@ +""" +Serial Proxy component for ESPHome. + +WARNING: This component is EXPERIMENTAL. The API (both Python configuration +and C++ interfaces) may change at any time without following the normal +breaking changes policy. Use at your own risk. + +Once the API is considered stable, this warning will be removed. + +Provides a proxy to/from a serial interface on the ESPHome device, allowing +Home Assistant to connect to the serial port and send/receive data to/from +an arbitrary serial device. +""" + +from esphome import pins +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["api", "uart"] + +MULTI_CONF = True + +serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy") +SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice) + +CONF_RTS_PIN = "rts_pin" +CONF_DTR_PIN = "dtr_pin" + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(SerialProxy), + cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + cg.add(cg.App.register_serial_proxy(var)) + cg.add_define("USE_SERIAL_PROXY") + + if CONF_RTS_PIN in config: + rts_pin = await cg.gpio_pin_expression(config[CONF_RTS_PIN]) + cg.add(var.set_rts_pin(rts_pin)) + + if CONF_DTR_PIN in config: + dtr_pin = await cg.gpio_pin_expression(config[CONF_DTR_PIN]) + cg.add(var.set_dtr_pin(dtr_pin)) + + # Request UART to wake the main loop when data arrives for low-latency processing + uart.request_wake_loop_on_rx() diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp new file mode 100644 index 0000000000..5a162b2dc9 --- /dev/null +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -0,0 +1,131 @@ +#include "serial_proxy.h" + +#ifdef USE_SERIAL_PROXY + +#include "esphome/core/log.h" + +#ifdef USE_API +#include "esphome/components/api/api_server.h" +#endif + +namespace esphome::serial_proxy { + +static const char *const TAG = "serial_proxy"; + +void SerialProxy::setup() { + // Set up modem control pins if configured + if (this->rts_pin_ != nullptr) { + this->rts_pin_->setup(); + this->rts_pin_->digital_write(this->rts_state_); + } + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->setup(); + this->dtr_pin_->digital_write(this->dtr_state_); + } +} + +void SerialProxy::loop() { + // Read available data from UART and forward to API clients + size_t available = this->available(); + if (available == 0) + return; + + // Read in chunks up to SERIAL_PROXY_MAX_READ_SIZE + uint8_t buffer[SERIAL_PROXY_MAX_READ_SIZE]; + size_t to_read = std::min(available, sizeof(buffer)); + + if (!this->read_array(buffer, to_read)) + return; + +#ifdef USE_API + if (api::global_api_server != nullptr) { + api::global_api_server->send_serial_proxy_data(this->instance_index_, buffer, to_read); + } +#endif +} + +void SerialProxy::dump_config() { + ESP_LOGCONFIG(TAG, + "Serial Proxy [%u]:\n" + " RTS Pin: %s\n" + " DTR Pin: %s", + this->instance_index_, this->rts_pin_ != nullptr ? "configured" : "not configured", + this->dtr_pin_ != nullptr ? "configured" : "not configured"); +} + +void SerialProxy::configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, + uint8_t data_size) { + ESP_LOGD(TAG, "Configuring serial proxy [%u]: baud=%u, flow_ctrl=%s, parity=%u, stop=%u, data=%u", + this->instance_index_, baudrate, YESNO(flow_control), parity, stop_bits, data_size); + + auto *uart_comp = this->parent_; + if (uart_comp == nullptr) { + ESP_LOGE(TAG, "UART component not available"); + return; + } + + // Apply UART parameters + uart_comp->set_baud_rate(baudrate); + uart_comp->set_stop_bits(stop_bits); + uart_comp->set_data_bits(data_size); + + // Map parity enum to UARTParityOptions + switch (parity) { + case 0: + uart_comp->set_parity(uart::UART_CONFIG_PARITY_NONE); + break; + case 1: + uart_comp->set_parity(uart::UART_CONFIG_PARITY_EVEN); + break; + case 2: + uart_comp->set_parity(uart::UART_CONFIG_PARITY_ODD); + break; + default: + ESP_LOGW(TAG, "Unknown parity value: %u, using NONE", parity); + uart_comp->set_parity(uart::UART_CONFIG_PARITY_NONE); + break; + } + + // Apply the new settings + // load_settings() is available on ESP8266 and ESP32 platforms +#if defined(USE_ESP8266) || defined(USE_ESP32) + uart_comp->load_settings(true); +#endif + + // Note: Hardware flow control configuration is stored but not yet applied + // to the UART hardware - this requires additional platform support + (void) flow_control; +} + +void SerialProxy::write(const uint8_t *data, size_t len) { + if (data == nullptr || len == 0) + return; + this->write_array(data, len); +} + +void SerialProxy::set_modem_pins(bool rts, bool dtr) { + ESP_LOGV(TAG, "Setting modem pins [%u]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); + + if (this->rts_pin_ != nullptr) { + this->rts_state_ = rts; + this->rts_pin_->digital_write(rts); + } + if (this->dtr_pin_ != nullptr) { + this->dtr_state_ = dtr; + this->dtr_pin_->digital_write(dtr); + } +} + +void SerialProxy::get_modem_pins(bool &rts, bool &dtr) const { + rts = this->rts_state_; + dtr = this->dtr_state_; +} + +void SerialProxy::flush_port() { + ESP_LOGV(TAG, "Flushing serial proxy [%u]", this->instance_index_); + this->flush(); +} + +} // namespace esphome::serial_proxy + +#endif // USE_SERIAL_PROXY diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h new file mode 100644 index 0000000000..4e62d7aa30 --- /dev/null +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -0,0 +1,80 @@ +#pragma once + +// WARNING: This component is EXPERIMENTAL. The API may change at any time +// without following the normal breaking changes policy. Use at your own risk. +// Once the API is considered stable, this warning will be removed. + +#include "esphome/core/defines.h" + +#ifdef USE_SERIAL_PROXY + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::serial_proxy { + +/// Maximum bytes to read from UART in a single loop iteration +static constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; + +class SerialProxy : public uart::UARTDevice, public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + /// Get the instance index (position in Application's serial_proxies_ vector) + uint32_t get_instance_index() const { return this->instance_index_; } + + /// Set the instance index (called by Application::register_serial_proxy) + void set_instance_index(uint32_t index) { this->instance_index_ = index; } + + /// Configure UART parameters and apply them + /// @param baudrate Baud rate in bits per second + /// @param flow_control True to enable hardware flow control + /// @param parity Parity setting (0=none, 1=even, 2=odd) + /// @param stop_bits Number of stop bits (1 or 2) + /// @param data_size Number of data bits (5-8) + void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size); + + /// Write data to the serial device + /// @param data Pointer to data buffer + /// @param len Number of bytes to write + void write(const uint8_t *data, size_t len); + + /// Set modem pin states (RTS and DTR) + /// @param rts Desired RTS pin state + /// @param dtr Desired DTR pin state + void set_modem_pins(bool rts, bool dtr); + + /// Get current modem pin states + /// @param[out] rts Current RTS pin state + /// @param[out] dtr Current DTR pin state + void get_modem_pins(bool &rts, bool &dtr) const; + + /// Flush the serial port (block until all TX data is sent) + void flush_port(); + + /// Set the RTS GPIO pin (from YAML configuration) + void set_rts_pin(GPIOPin *pin) { this->rts_pin_ = pin; } + + /// Set the DTR GPIO pin (from YAML configuration) + void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; } + + protected: + /// Instance index for identifying this proxy in API messages + uint32_t instance_index_{0}; + + /// Optional GPIO pins for modem control + GPIOPin *rts_pin_{nullptr}; + GPIOPin *dtr_pin_{nullptr}; + + /// Current modem pin states + bool rts_state_{false}; + bool dtr_state_{false}; +}; + +} // namespace esphome::serial_proxy + +#endif // USE_SERIAL_PROXY diff --git a/esphome/core/application.h b/esphome/core/application.h index 30611227a2..9ae6e85ffa 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -94,6 +94,9 @@ #ifdef USE_INFRARED #include "esphome/components/infrared/infrared.h" #endif +#ifdef USE_SERIAL_PROXY +#include "esphome/components/serial_proxy/serial_proxy.h" +#endif #ifdef USE_EVENT #include "esphome/components/event/event.h" #endif @@ -234,6 +237,13 @@ class Application { void register_infrared(infrared::Infrared *infrared) { this->infrareds_.push_back(infrared); } #endif +#ifdef USE_SERIAL_PROXY + void register_serial_proxy(serial_proxy::SerialProxy *proxy) { + proxy->set_instance_index(this->serial_proxies_.size()); + this->serial_proxies_.push_back(proxy); + } +#endif + #ifdef USE_EVENT void register_event(event::Event *event) { this->events_.push_back(event); } #endif @@ -473,6 +483,10 @@ class Application { GET_ENTITY_METHOD(infrared::Infrared, infrared, infrareds) #endif +#ifdef USE_SERIAL_PROXY + auto &get_serial_proxies() const { return this->serial_proxies_; } +#endif + #ifdef USE_EVENT auto &get_events() const { return this->events_; } GET_ENTITY_METHOD(event::Event, event, events) @@ -690,6 +704,9 @@ class Application { #ifdef USE_INFRARED StaticVector infrareds_{}; #endif +#ifdef USE_SERIAL_PROXY + std::vector serial_proxies_{}; +#endif #ifdef USE_UPDATE StaticVector updates_{}; #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7e6df31ea2..90ff7219a6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -109,6 +109,7 @@ #define USE_SAFE_MODE_CALLBACK #define USE_SELECT #define USE_SENSOR +#define USE_SERIAL_PROXY #define USE_STATUS_LED #define USE_STATUS_SENSOR #define USE_SWITCH diff --git a/tests/components/serial_proxy/common.yaml b/tests/components/serial_proxy/common.yaml new file mode 100644 index 0000000000..1a7a498f16 --- /dev/null +++ b/tests/components/serial_proxy/common.yaml @@ -0,0 +1,8 @@ +wifi: + ssid: MySSID + password: password1 + +api: + +serial_proxy: + - id: serial_proxy_1 diff --git a/tests/components/serial_proxy/test.esp32-idf.yaml b/tests/components/serial_proxy/test.esp32-idf.yaml new file mode 100644 index 0000000000..b415125e84 --- /dev/null +++ b/tests/components/serial_proxy/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/serial_proxy/test.esp8266-ard.yaml b/tests/components/serial_proxy/test.esp8266-ard.yaml new file mode 100644 index 0000000000..96ab4ef6ac --- /dev/null +++ b/tests/components/serial_proxy/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/serial_proxy/test.rp2040-ard.yaml b/tests/components/serial_proxy/test.rp2040-ard.yaml new file mode 100644 index 0000000000..b28f2b5e05 --- /dev/null +++ b/tests/components/serial_proxy/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + +<<: !include common.yaml