From 3162a16b0557c0b643c67517da21f3d8db6c60ee Mon Sep 17 00:00:00 2001 From: kbx81 Date: Wed, 2 Sep 2026 16:07:50 -0500 Subject: [PATCH 01/12] [serial_proxy] Add tap interface and port mode Add SerialProxyTap, a protocol-agnostic observer interface that lets a companion component watch the bytes flowing through a proxied port and inject bytes of its own (protocol acknowledgements, for example) without owning the port. The tap machinery is compiled in only when a tap component defines USE_SERIAL_PROXY_TAP, so ports without one pay nothing. Add a per-port mode (RAW or PROTOCOL) with a matching API message so clients control whether the tap is active. The mode belongs to the client session: it resets to RAW whenever the subscriber disconnects, and RAW is guaranteed inert so a client can flash firmware through the port without protocol bytes being injected. Bumps the API minor version to 17. Co-Authored-By: puddly <32534428+puddly@users.noreply.github.com> --- esphome/components/api/api.proto | 21 +++ esphome/components/api/api_connection.cpp | 11 +- esphome/components/api/api_connection.h | 1 + esphome/components/api/api_pb2.cpp | 13 ++ esphome/components/api/api_pb2.h | 20 +++ esphome/components/api/api_pb2_dump.cpp | 16 ++ esphome/components/api/api_pb2_service.cpp | 11 ++ esphome/components/api/api_pb2_service.h | 3 + esphome/components/serial_proxy/__init__.py | 17 ++- .../components/serial_proxy/serial_proxy.cpp | 141 +++++++++++++++--- .../components/serial_proxy/serial_proxy.h | 81 +++++++++- esphome/core/defines.h | 1 + tests/components/serial_proxy/common.yaml | 1 + 13 files changed, 315 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 3a0e0abea9..2921d94d47 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -77,6 +77,7 @@ service APIConnection { rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {} rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} + rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} } @@ -2838,6 +2839,26 @@ message SerialProxyRequestResponse { string error_message = 4; // Additional detail on failure (optional) } +// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL +// activates the port's protocol-aware tap (if one is configured), letting it observe +// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap +// speaks is a property of the device configuration, discoverable from the tap +// component's own API surface. A client that is about to flash firmware selects RAW +// first, which definitively disables that injection. +enum SerialProxyMode { + SERIAL_PROXY_MODE_RAW = 0; + SERIAL_PROXY_MODE_PROTOCOL = 1; +} + +message SerialProxySetModeRequest { + option (id) = 151; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyMode mode = 2; +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== message BluetoothSetConnectionParamsRequest { option (id) = 145; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 9c609aa047..abe4d75841 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1673,6 +1673,15 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { send_serial_proxy_ack(this, msg.instance, msg.type, status); } +void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + return; + } + proxies[msg.instance]->set_mode(this, msg.mode); +} + void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { if (!this->send_message(msg)) { ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full"); @@ -1799,7 +1808,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 16; + resp.api_version_minor = 17; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index a4c49dccf4..c19a33ca9b 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -244,6 +244,7 @@ class APIConnection final : public APIServerConnectionBase { void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg); void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &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); #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 2de1f0a15c..7f162d9c15 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4253,6 +4253,19 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { size += ProtoSize::calc_length(1, this->error_message.size()); return size; } +bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { + switch (field_id) { + case 1: + this->instance = value; + break; + case 2: + this->mode = static_cast(value); + break; + default: + return false; + } + return true; +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5c3429a63a..fbfe5998df 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -366,6 +366,10 @@ enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_PORT_IN_USE = 5, SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6, }; +enum SerialProxyMode : uint32_t { + SERIAL_PROXY_MODE_RAW = 0, + SERIAL_PROXY_MODE_PROTOCOL = 1, +}; #endif } // namespace enums @@ -3403,6 +3407,22 @@ class SerialProxyRequestResponse final : public ProtoMessage { protected: }; +class SerialProxySetModeRequest final : public ProtoDecodableMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 151; + static constexpr uint8_t ESTIMATED_SIZE = 6; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); } +#endif + uint32_t instance{0}; + enums::SerialProxyMode mode{}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; +}; #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index dced81ee30..3d85b1276a 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -878,6 +878,16 @@ template<> const char *proto_enum_to_string(enums::Ser return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::SerialProxyMode value) { + switch (value) { + case enums::SERIAL_PROXY_MODE_RAW: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW"); + case enums::SERIAL_PROXY_MODE_PROTOCOL: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #endif const char *HelloRequest::dump_to(DumpBuffer &out) const { @@ -2805,6 +2815,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error_message"), this->error_message); return out.c_str(); } +const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest")); + dump_field(out, ESPHOME_PSTR("instance"), this->instance); + dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); + return out.c_str(); +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 65c7b8858c..172062be63 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -712,6 +712,17 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_device_capabilities_request(); break; } +#ifdef USE_SERIAL_PROXY + case SerialProxySetModeRequest::MESSAGE_TYPE: { + SerialProxySetModeRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg); +#endif + this->on_serial_proxy_set_mode_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 6abdf7093e..a4dfd6a366 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -235,6 +235,9 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; +#endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 4186fcf8b1..158c9609e4 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -18,7 +18,7 @@ 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, CONF_NAME +from esphome.const import CONF_ID, CONF_MODE, CONF_NAME from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -30,6 +30,7 @@ MULTI_CONF = True serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy") SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice) +SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap") api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") @@ -39,6 +40,16 @@ SERIAL_PROXY_PORT_TYPES = { "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, } +SerialProxyMode = api_enums_ns.enum("SerialProxyMode") +# The mode a port starts in. `raw` is a plain byte pipe; `protocol` activates the +# port's tap (if one is configured), letting it observe traffic and inject protocol +# bytes such as acknowledgements. Clients may change it at runtime, so this only +# decides what the device boots into. +SERIAL_PROXY_MODES = { + "RAW": SerialProxyMode.SERIAL_PROXY_MODE_RAW, + "PROTOCOL": SerialProxyMode.SERIAL_PROXY_MODE_PROTOCOL, +} + CONF_DTR_PIN = "dtr_pin" CONF_PORT_TYPE = "port_type" CONF_RTS_PIN = "rts_pin" @@ -63,6 +74,9 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(SerialProxy), cv.Required(CONF_NAME): cv.string_strict, cv.Required(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True), + cv.Optional(CONF_MODE, default="RAW"): cv.enum( + SERIAL_PROXY_MODES, upper=True + ), cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, } @@ -87,6 +101,7 @@ 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])) + cg.add(var.set_mode(config[CONF_MODE])) cg.add_define("USE_SERIAL_PROXY") # Track instance count for the FINAL priority define diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index c1c1510643..5d0e9cbbd7 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -29,26 +29,55 @@ void SerialProxy::setup() { #ifdef USE_API // 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_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 + // the loop enabled is what lets that finish; without it the tap would stall until a + // client happened to subscribe. + if (this->tap_ != nullptr && this->tap_->tap_needs_port()) { + return; + } #endif // No subscriber at startup; disable loop until a client subscribes this->disable_loop(); } -void SerialProxy::loop() { -#ifdef USE_API - // Safety check — loop should only run when subscribed, but guard against races - if (this->api_connection_ == nullptr) [[unlikely]] { - this->disable_loop(); +void SerialProxy::reset_mode_() { + // The mode belongs to a session, not to the port. Carrying a departed client's choice + // over to the next one would inject protocol bytes into a stream that never asked for + // them -- a firmware upload, or any client built before this request existed and so + // unable to turn it off. Guessing RAW is the safe direction: a client that wanted + // protocol handling and did not ask for it merely sends its own acknowledgements. + if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) { return; } + ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_); + this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW; +} +void SerialProxy::loop() { +#ifdef USE_API // Detect subscriber disconnect - if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() || - !api_is_connected()) { + if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() || + !this->api_connection_->is_connection_setup() || !api_is_connected())) { ESP_LOGW(TAG, "Subscriber disconnected"); this->api_connection_ = nullptr; + this->reset_mode_(); + } + + // With no subscriber there is normally nothing to do, but a tap may still need the port + // read -- it does its protocol work precisely while nobody else is listening. + if (this->api_connection_ == nullptr) [[unlikely]] { +#ifdef USE_SERIAL_PROXY_TAP + if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) { + this->disable_loop(); + return; + } +#else this->disable_loop(); return; +#endif } // Read available data from UART and forward to subscribed client @@ -69,24 +98,67 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { if (!this->read_array(buffer, to_read)) return; +#ifdef USE_SERIAL_PROXY_TAP + // Before forwarding, so a tap that answers the device (an acknowledgement, say) is not + // waiting on the network round trip to a subscriber that may not even exist. + if (this->tap_observing_()) { + this->tap_->on_device_rx(buffer, to_read); + } +#endif + + if (this->api_connection_ == nullptr) { + return; + } this->outgoing_msg_.set_data(buffer, to_read); this->api_connection_->send_serial_proxy_data(this->outgoing_msg_); } #endif +#ifdef USE_SERIAL_PROXY_TAP + +bool SerialProxy::tap_observing_() const { + if (this->tap_ == nullptr) { + return false; + } + // A tap that needs the port is mid-protocol-work of its own -- the boot-time handshake + // with the device, which runs before any client has connected and so before anyone could + // have chosen a mode. Withholding bytes from it there would strand it, so it is served + // regardless of mode. + if (this->tap_->tap_needs_port()) { + return true; + } + // Otherwise the mode decides. RAW must be inert: a client that flips to RAW before + // flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes + // into it, and "the tap turned out not to recognise the stream" is not good enough. + return this->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL; +} + +void SerialProxy::tap_pump() { +#ifdef USE_API + const size_t available = this->available(); + if (available > 0) { + this->read_and_send_(available); + } +#endif +} +#endif + void SerialProxy::dump_config() { - ESP_LOGCONFIG(TAG, - "Serial Proxy [%" PRIu32 "]:\n" - " Name: %s\n" - " Port Type: %s\n" - " 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->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")); + ESP_LOGCONFIG( + TAG, + "Serial Proxy [%" PRIu32 "]:\n" + " Name: %s\n" + " Port Type: %s\n" + " Mode: %s\n" + " 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->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW"), + 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")); } SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, @@ -159,6 +231,29 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } +void SerialProxy::set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { +#ifdef USE_API + if (this->port_claimed_by_other_(api_connection)) { + ESP_LOGW(TAG, "Ignoring mode request from client without port access [%" PRIu32 "]", this->instance_index_); + return; + } +#endif + ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, + mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? "PROTOCOL" : "RAW"); + const bool leaving_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; + this->mode_ = mode; + +#ifdef USE_SERIAL_PROXY_TAP + // Only for an explicit client request, not for reset_mode_() at the end of a session: + // an ordinary disconnect says nothing about the device, whereas a client deliberately + // asking for raw bytes usually precedes changing what the device is. + if (leaving_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_disabled(); + } +#endif +} + void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) { #ifdef USE_API // Bytes from a client other than the live subscriber would interleave with the @@ -171,6 +266,13 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui if (data == nullptr || len == 0) return; this->write_array(data, len); + +#ifdef USE_SERIAL_PROXY_TAP + // After the write, so the tap observes the same ordering the device does + if (this->tap_observing_()) { + this->tap_->on_client_tx(data, len); + } +#endif } SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { @@ -264,6 +366,7 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } this->api_connection_ = nullptr; + this->reset_mode_(); this->disable_loop(); ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_OK; diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index a0e47ee686..19a3a4f063 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -26,6 +26,7 @@ class APIConnection; namespace enums { enum SerialProxyPortType : uint32_t; enum SerialProxyRequestType : uint32_t; +enum SerialProxyMode : uint32_t; } // namespace enums } // namespace esphome::api @@ -52,6 +53,35 @@ enum class SerialProxyResult : uint8_t { /// Maximum bytes to read from UART in a single loop iteration inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; +#ifdef USE_SERIAL_PROXY_TAP +/// Observes a port's traffic without owning it, and may inject bytes of its own. +/// +/// This exists so protocol-aware behaviour can be layered onto a plain byte pipe without +/// the pipe knowing anything about the protocol: the tap is compiled in only when some +/// component asks for one, so a proxy carrying an RS485 meter pays nothing for it. +/// +/// A tap is an observer, never a gatekeeper -- it cannot suppress or alter the bytes +/// flowing in either direction, so a misbehaving tap cannot corrupt the stream. +class SerialProxyTap { + public: + /// Bytes read from the device, before they are forwarded to any subscriber. + virtual void on_device_rx(const uint8_t *data, size_t len) = 0; + + /// Bytes a subscriber sent towards the device, after they have been written. + virtual void on_client_tx(const uint8_t *data, size_t len) = 0; + + /// True when the port must keep reading even with no subscriber attached, so a tap can + /// do its own protocol work while nobody is listening. + virtual bool tap_needs_port() const = 0; + + /// A client explicitly turned protocol handling off for this port. Distinct from the + /// automatic reset when a session ends: this one means a client intends to do something + /// 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; +}; +#endif + class SerialProxy final : public uart::UARTDevice, public Component { public: void setup() override; @@ -77,6 +107,15 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get the port type api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; } + /// Set the initial mode (from YAML configuration) + void set_mode(api::enums::SerialProxyMode mode) { this->mode_ = mode; } + + /// Get the current mode + api::enums::SerialProxyMode get_mode() const { return this->mode_; } + + /// Handle a mode change requested by an API client + void set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); + /// Configure UART parameters and apply them /// @param api_connection The API connection requesting the change /// @param baudrate Baud rate in bits per second @@ -121,15 +160,48 @@ 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_TAP + /// Attach a traffic observer. At most one, set once at setup time. + void set_tap(SerialProxyTap *tap) { this->tap_ = tap; } + + /// Write bytes originating from the tap rather than from a client. Bypasses the + /// subscriber ownership check, since the tap is part of the device, not a client of it. + void write_from_tap(const uint8_t *data, size_t len) { this->write_array(data, len); } + + /// Resume reading after a tap's needs change. loop() disables itself when there is + /// neither a subscriber nor a tap that wants the port, so a tap starting fresh work + /// must ask for it back. + void tap_request_port() { this->enable_loop(); } + + /// Whether the underlying device is present. On a USB UART this tracks enumeration, so + /// a tap can notice the device being unplugged and plugged back in. + bool is_device_connected() const { return this->parent_->is_connected(); } + + /// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the + /// main loop is running -- during setup, for instance, while a component is still + /// blocking on can_proceed(). + void tap_pump(); +#endif + protected: #ifdef USE_API - /// Read from UART and send to API client (slow path with 256-byte stack buffer) + /// Read from UART, hand the bytes to any tap, and forward them to a subscriber + /// (slow path with a 256-byte stack buffer) void read_and_send_(size_t available); /// True when a live subscriber other than the given connection holds the port bool port_claimed_by_other_(api::APIConnection *api_connection) const; #endif + /// Return the port to RAW when a subscriber goes away, so the mode never outlives it. + /// Not tap-gated: the mode is a client-visible property whether or not a tap acts on it. + void reset_mode_(); + +#ifdef USE_SERIAL_PROXY_TAP + /// True when the tap should be shown the traffic passing through this port + bool tap_observing_() const; +#endif + /// Instance index for identifying this proxy in API messages uint32_t instance_index_{0}; @@ -147,6 +219,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Port type api::enums::SerialProxyPortType port_type_{}; + /// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW + api::enums::SerialProxyMode mode_{}; + /// Optional GPIO pins for modem control GPIOPin *rts_pin_{nullptr}; GPIOPin *dtr_pin_{nullptr}; @@ -154,6 +229,10 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Current modem pin states bool rts_state_{false}; bool dtr_state_{false}; + +#ifdef USE_SERIAL_PROXY_TAP + SerialProxyTap *tap_{nullptr}; +#endif }; } // namespace esphome::serial_proxy diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7af41409fd..c5ac18a619 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -179,6 +179,7 @@ #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY +#define USE_SERIAL_PROXY_TAP #define USE_SETUP_PRIORITY_OVERRIDE #define USE_STATUS_LED #define USE_STATUS_SENSOR diff --git a/tests/components/serial_proxy/common.yaml b/tests/components/serial_proxy/common.yaml index 6f03cf95df..a93d22e410 100644 --- a/tests/components/serial_proxy/common.yaml +++ b/tests/components/serial_proxy/common.yaml @@ -8,3 +8,4 @@ serial_proxy: - id: serial_proxy_1 name: Test Serial Port port_type: RS232 + mode: protocol From ab8e180ff573884415435f9cd177af362f544c6a Mon Sep 17 00:00:00 2001 From: kbx81 Date: Wed, 2 Sep 2026 19:16:38 -0500 Subject: [PATCH 02/12] [serial_proxy] Move SerialProxySetModeRequest to ID 152 IDs 150 and 151 were claimed on dev (DeviceCapabilitiesResponse, ZWaveProxyRequestResponse) after this branch was cut. --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_pb2.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 2921d94d47..34a04c7336 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2851,7 +2851,7 @@ enum SerialProxyMode { } message SerialProxySetModeRequest { - option (id) = 151; + option (id) = 152; option (source) = SOURCE_CLIENT; option (ifdef) = "USE_SERIAL_PROXY"; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index fbfe5998df..7438b4cc48 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3409,7 +3409,7 @@ class SerialProxyRequestResponse final : public ProtoMessage { }; class SerialProxySetModeRequest final : public ProtoDecodableMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 151; + static constexpr uint16_t MESSAGE_TYPE = 152; static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); } From e723d404e2d73844ff3502c70a56fe143c556d52 Mon Sep 17 00:00:00 2001 From: kbx81 Date: Wed, 2 Sep 2026 21:25:34 -0500 Subject: [PATCH 03/12] [serial_proxy] Add set_mode to the benchmark stub The benchmark harness compiles api_connection.cpp against stub component headers, so the stub needs the new client-request method. --- .../stubs/esphome/components/serial_proxy/serial_proxy.h | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h index 6fc20f3350..f1702c1ffc 100644 --- a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -40,6 +40,7 @@ class SerialProxy { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {} + void set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) {} SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } From 89d00c6d9308a0503cad1e299435e75feb30541f Mon Sep 17 00:00:00 2001 From: kbx81 Date: Wed, 2 Sep 2026 21:30:52 -0500 Subject: [PATCH 04/12] [serial_proxy] Acknowledge set_mode requests Follow the acknowledgement pattern from #18312: set_mode now returns a SerialProxyResult and the handler answers with SerialProxyRequestResponse (type SET_MODE). This matters most for a client switching to RAW before flashing firmware through the port: without an ack, a refused request (another client holds the port) is silently dropped and the client cannot tell that protocol bytes may still be injected. --- esphome/components/api/api.proto | 1 + esphome/components/api/api_connection.cpp | 7 ++++++- esphome/components/api/api_pb2.h | 1 + esphome/components/api/api_pb2_dump.cpp | 2 ++ esphome/components/serial_proxy/serial_proxy.cpp | 5 +++-- esphome/components/serial_proxy/serial_proxy.h | 2 +- .../stubs/esphome/components/serial_proxy/serial_proxy.h | 4 +++- 7 files changed, 17 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 34a04c7336..8e7bdbe45d 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2803,6 +2803,7 @@ enum SerialProxyRequestType { // error the device answers with INVALID_ARGUMENT. SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17) } enum SerialProxyStatus { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index abe4d75841..0057f3804e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1661,6 +1661,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { break; case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE: case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: // Response-only discriminators; never valid in a request ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast(msg.type)); status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT; @@ -1677,9 +1678,13 @@ void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeReq auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT); return; } - proxies[msg.instance]->set_mode(this, msg.mode); + serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode(this, msg.mode); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + serial_proxy_result_to_status(result)); } void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7438b4cc48..799aaa27b5 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -356,6 +356,7 @@ enum SerialProxyRequestType : uint32_t { SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2, SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3, SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4, + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5, }; enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_OK = 0, diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3d85b1276a..bb244973a1 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -854,6 +854,8 @@ template<> const char *proto_enum_to_string(enums return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE"); case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS"); + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: + return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE"); default: return ESPHOME_PSTR("UNKNOWN"); } diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 5d0e9cbbd7..45347548d7 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -231,11 +231,11 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } -void SerialProxy::set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { +SerialProxyResult SerialProxy::set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { #ifdef USE_API if (this->port_claimed_by_other_(api_connection)) { ESP_LOGW(TAG, "Ignoring mode request from client without port access [%" PRIu32 "]", this->instance_index_); - return; + return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, @@ -252,6 +252,7 @@ void SerialProxy::set_mode(api::APIConnection *api_connection, api::enums::Seria this->tap_->on_protocol_disabled(); } #endif + return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) { diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 19a3a4f063..52ece5ad7f 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -114,7 +114,7 @@ class SerialProxy final : public uart::UARTDevice, public Component { api::enums::SerialProxyMode get_mode() const { return this->mode_; } /// Handle a mode change requested by an API client - void set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); + SerialProxyResult set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); /// Configure UART parameters and apply them /// @param api_connection The API connection requesting the change diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h index f1702c1ffc..b746fa8940 100644 --- a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -40,7 +40,9 @@ class SerialProxy { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {} - void set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) {} + SerialProxyResult set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { + return SerialProxyResult::SERIAL_PROXY_RESULT_OK; + } SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } From 88402743d53e2f682022d5b0107fc1547ce92aa5 Mon Sep 17 00:00:00 2001 From: kbx81 Date: Wed, 2 Sep 2026 22:27:52 -0500 Subject: [PATCH 05/12] [serial_proxy] Enforce session scoping and RAW inertness for the port mode Address review findings: - Only the live subscriber may set the mode, so a mode set by a client that never subscribes cannot persist past its session - With a subscriber attached, the mode alone decides whether the tap is served; tap_needs_port() bypasses it only while the port is unheld, and write_from_tap() is gated the same way, so RAW is inert by code - The explicit UNSUBSCRIBE path keeps the loop alive for a tap that still needs the port, mirroring the disconnect path in loop() - Mode values from the wire are validated; unknown values are refused with INVALID_ARGUMENT instead of stored and acknowledged OK - Add a test variant that defines USE_SERIAL_PROXY_TAP so the tap code paths compile in a real build --- esphome/components/api/api.proto | 1 + .../components/serial_proxy/serial_proxy.cpp | 28 +++++++++++++------ .../components/serial_proxy/serial_proxy.h | 12 ++++++-- .../serial_proxy/test-tap.esp32-idf.yaml | 14 ++++++++++ 4 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 tests/components/serial_proxy/test-tap.esp32-idf.yaml diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 8e7bdbe45d..6e5d0d6200 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2851,6 +2851,7 @@ enum SerialProxyMode { SERIAL_PROXY_MODE_PROTOCOL = 1; } +// Only the subscribed client may change the mode; others are refused with PORT_IN_USE. message SerialProxySetModeRequest { option (id) = 152; option (source) = SOURCE_CLIENT; diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 45347548d7..36e5aee678 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -120,11 +120,10 @@ bool SerialProxy::tap_observing_() const { if (this->tap_ == nullptr) { return false; } - // A tap that needs the port is mid-protocol-work of its own -- the boot-time handshake - // with the device, which runs before any client has connected and so before anyone could - // have chosen a mode. Withholding bytes from it there would strand it, so it is served - // regardless of mode. - if (this->tap_->tap_needs_port()) { + // With no subscriber, a tap doing its own protocol work (the boot-time handshake with + // the device, say) is served regardless of mode -- nobody has chosen one yet. Once a + // subscriber holds the port, the mode alone decides, so RAW stays inert. + if (this->api_connection_ == nullptr && this->tap_->tap_needs_port()) { return true; } // Otherwise the mode decides. RAW must be inert: a client that flips to RAW before @@ -233,13 +232,19 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin SerialProxyResult SerialProxy::set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { #ifdef USE_API - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring mode request from client without port access [%" PRIu32 "]", this->instance_index_); + // Only the live subscriber may change the mode, so the mode cannot outlive a session + if (this->api_connection_ != api_connection) { + ESP_LOGW(TAG, "Ignoring mode request from client without port subscription [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif + // Values come from a remote client + if (mode != api::enums::SERIAL_PROXY_MODE_RAW && mode != api::enums::SERIAL_PROXY_MODE_PROTOCOL) { + ESP_LOGW(TAG, "Invalid mode: %" PRIu32, static_cast(mode)); + return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT; + } ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, - mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? "PROTOCOL" : "RAW"); + mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); const bool leaving_protocol_mode = this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; this->mode_ = mode; @@ -368,7 +373,14 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn } this->api_connection_ = nullptr; this->reset_mode_(); +#ifdef USE_SERIAL_PROXY_TAP + // Keep the loop alive for a tap that still needs the port (mirrors loop()) + if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) { + this->disable_loop(); + } +#else this->disable_loop(); +#endif ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_OK; default: diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 52ece5ad7f..a48e69fba7 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -71,7 +71,8 @@ class SerialProxyTap { virtual void on_client_tx(const uint8_t *data, size_t len) = 0; /// True when the port must keep reading even with no subscriber attached, so a tap can - /// do its own protocol work while nobody is listening. + /// do its own protocol work while nobody is listening. Honoured only while no + /// subscriber holds the port; with one attached, the port mode alone decides. virtual bool tap_needs_port() const = 0; /// A client explicitly turned protocol handling off for this port. Distinct from the @@ -165,8 +166,13 @@ class SerialProxy final : public uart::UARTDevice, public Component { void set_tap(SerialProxyTap *tap) { this->tap_ = tap; } /// Write bytes originating from the tap rather than from a client. Bypasses the - /// subscriber ownership check, since the tap is part of the device, not a client of it. - void write_from_tap(const uint8_t *data, size_t len) { this->write_array(data, len); } + /// subscriber ownership check, but only while the tap is being served bytes -- so a + /// port in RAW mode with a subscriber attached stays inert. + void write_from_tap(const uint8_t *data, size_t len) { + if (this->tap_observing_()) { + this->write_array(data, len); + } + } /// Resume reading after a tap's needs change. loop() disables itself when there is /// neither a subscriber nor a tap that wants the port, so a tap starting fresh work diff --git a/tests/components/serial_proxy/test-tap.esp32-idf.yaml b/tests/components/serial_proxy/test-tap.esp32-idf.yaml new file mode 100644 index 0000000000..5522e53c47 --- /dev/null +++ b/tests/components/serial_proxy/test-tap.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +# Compile the tap code paths; no tap is attached, so this exercises the +# null-tap branches that a normal build never defines. +esphome: + platformio_options: + build_flags: + - "-DUSE_SERIAL_PROXY_TAP" + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + serial_proxy: !include common.yaml From 14f6d44ac272f84d1ba095d7ac84f033803c18dc Mon Sep 17 00:00:00 2001 From: kbx81 Date: Wed, 2 Sep 2026 23:04:29 -0500 Subject: [PATCH 06/12] [serial_proxy] Make set_mode acknowledgements report the real outcome - Refuse PROTOCOL with NOT_SUPPORTED when the port has no tap, so a client cannot mistake a plain pipe for an active tap - Skip tap_pump() when neither the tap nor a subscriber would receive the bytes, instead of draining the FIFO into the void - Rename the client-facing overload to set_mode_from_client, matching write_from_client - Document that PORT_IN_USE also covers callers that never subscribed, and that the YAML mode applies only until the first session ends --- esphome/components/api/api.proto | 4 +++- esphome/components/api/api_connection.cpp | 2 +- esphome/components/serial_proxy/__init__.py | 7 ++++--- .../components/serial_proxy/serial_proxy.cpp | 17 ++++++++++++++++- esphome/components/serial_proxy/serial_proxy.h | 4 ++-- .../components/serial_proxy/serial_proxy.h | 2 +- 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 6e5d0d6200..c6b9d8690f 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2851,7 +2851,9 @@ enum SerialProxyMode { SERIAL_PROXY_MODE_PROTOCOL = 1; } -// Only the subscribed client may change the mode; others are refused with PORT_IN_USE. +// Only the subscribed client may change the mode; any other caller -- including one that +// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED +// when the port has no protocol-aware tap configured. message SerialProxySetModeRequest { option (id) = 152; option (source) = SOURCE_CLIENT; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0057f3804e..bb6bb0a720 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1682,7 +1682,7 @@ void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeReq enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT); return; } - serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode(this, msg.mode); + serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode); send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, serial_proxy_result_to_status(result)); } diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 158c9609e4..96400ebc89 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -41,10 +41,11 @@ SERIAL_PROXY_PORT_TYPES = { } SerialProxyMode = api_enums_ns.enum("SerialProxyMode") -# The mode a port starts in. `raw` is a plain byte pipe; `protocol` activates the +# The mode a port boots into. `raw` is a plain byte pipe; `protocol` activates the # port's tap (if one is configured), letting it observe traffic and inject protocol -# bytes such as acknowledgements. Clients may change it at runtime, so this only -# decides what the device boots into. +# bytes such as acknowledgements. The mode returns to `raw` whenever a client session +# ends, so this value applies only until the first session ends; after that, clients +# select the mode at runtime. SERIAL_PROXY_MODES = { "RAW": SerialProxyMode.SERIAL_PROXY_MODE_RAW, "PROTOCOL": SerialProxyMode.SERIAL_PROXY_MODE_PROTOCOL, diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 36e5aee678..f135c4313a 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -134,6 +134,10 @@ bool SerialProxy::tap_observing_() const { void SerialProxy::tap_pump() { #ifdef USE_API + // Nothing would consume the bytes; leave them in the FIFO + if (!this->tap_observing_() && this->api_connection_ == nullptr) { + return; + } const size_t available = this->available(); if (available > 0) { this->read_and_send_(available); @@ -230,7 +234,8 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } -SerialProxyResult SerialProxy::set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { +SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_connection, + api::enums::SerialProxyMode mode) { #ifdef USE_API // Only the live subscriber may change the mode, so the mode cannot outlive a session if (this->api_connection_ != api_connection) { @@ -243,6 +248,16 @@ SerialProxyResult SerialProxy::set_mode(api::APIConnection *api_connection, api: ESP_LOGW(TAG, "Invalid mode: %" PRIu32, static_cast(mode)); return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT; } + // PROTOCOL on a port with no tap would be a silent no-op; refuse so the client knows + if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL) { +#ifdef USE_SERIAL_PROXY_TAP + if (this->tap_ == nullptr) +#endif + { + ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED; + } + } ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); const bool leaving_protocol_mode = diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index a48e69fba7..4c03ed959c 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -115,7 +115,7 @@ class SerialProxy final : public uart::UARTDevice, public Component { api::enums::SerialProxyMode get_mode() const { return this->mode_; } /// Handle a mode change requested by an API client - SerialProxyResult set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); + SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); /// Configure UART parameters and apply them /// @param api_connection The API connection requesting the change @@ -176,7 +176,7 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Resume reading after a tap's needs change. loop() disables itself when there is /// neither a subscriber nor a tap that wants the port, so a tap starting fresh work - /// must ask for it back. + /// must ask for it back. Must be called from the main loop. void tap_request_port() { this->enable_loop(); } /// Whether the underlying device is present. On a USB UART this tracks enumeration, so diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h index b746fa8940..7da6fff017 100644 --- a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -40,7 +40,7 @@ class SerialProxy { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {} - SerialProxyResult set_mode(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { + SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { From fa5e784cbec052068d187ed28a181144ebd35125 Mon Sep 17 00:00:00 2001 From: kbx81 Date: Wed, 2 Sep 2026 23:31:05 -0500 Subject: [PATCH 07/12] [serial_proxy] Drop the YAML mode option The boot mode had no coherent job left: before any subscriber the tap is served via tap_needs_port() regardless of mode, 1.17+ clients select the mode explicitly after subscribing, and the only remaining effect was arming the tap for a first-session client that never asked for it and could not turn it off. The mode is now purely a session property of the API: ports always boot RAW. Also polish the tap contract per review: expose tap_is_observed(), return false from write_from_tap() when the bytes are dropped, and document that tap_pump() must not be called from tap callbacks. --- esphome/components/serial_proxy/__init__.py | 17 +----------- .../components/serial_proxy/serial_proxy.cpp | 27 +++++++++---------- .../components/serial_proxy/serial_proxy.h | 25 +++++++++-------- tests/components/serial_proxy/common.yaml | 1 - 4 files changed, 27 insertions(+), 43 deletions(-) diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 96400ebc89..b6e780fabd 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -18,7 +18,7 @@ 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, CONF_MODE, CONF_NAME +from esphome.const import CONF_ID, CONF_NAME from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -40,17 +40,6 @@ SERIAL_PROXY_PORT_TYPES = { "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, } -SerialProxyMode = api_enums_ns.enum("SerialProxyMode") -# The mode a port boots into. `raw` is a plain byte pipe; `protocol` activates the -# port's tap (if one is configured), letting it observe traffic and inject protocol -# bytes such as acknowledgements. The mode returns to `raw` whenever a client session -# ends, so this value applies only until the first session ends; after that, clients -# select the mode at runtime. -SERIAL_PROXY_MODES = { - "RAW": SerialProxyMode.SERIAL_PROXY_MODE_RAW, - "PROTOCOL": SerialProxyMode.SERIAL_PROXY_MODE_PROTOCOL, -} - CONF_DTR_PIN = "dtr_pin" CONF_PORT_TYPE = "port_type" CONF_RTS_PIN = "rts_pin" @@ -75,9 +64,6 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(SerialProxy), cv.Required(CONF_NAME): cv.string_strict, cv.Required(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True), - cv.Optional(CONF_MODE, default="RAW"): cv.enum( - SERIAL_PROXY_MODES, upper=True - ), cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, } @@ -102,7 +88,6 @@ 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])) - cg.add(var.set_mode(config[CONF_MODE])) cg.add_define("USE_SERIAL_PROXY") # Track instance count for the FINAL priority define diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index f135c4313a..0877de66c4 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -147,21 +147,18 @@ void SerialProxy::tap_pump() { #endif void SerialProxy::dump_config() { - ESP_LOGCONFIG( - TAG, - "Serial Proxy [%" PRIu32 "]:\n" - " Name: %s\n" - " Port Type: %s\n" - " Mode: %s\n" - " 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->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW"), - 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")); + ESP_LOGCONFIG(TAG, + "Serial Proxy [%" PRIu32 "]:\n" + " Name: %s\n" + " Port Type: %s\n" + " 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->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")); } SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 4c03ed959c..2b20eef05e 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -108,12 +108,6 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get the port type api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; } - /// Set the initial mode (from YAML configuration) - void set_mode(api::enums::SerialProxyMode mode) { this->mode_ = mode; } - - /// Get the current mode - api::enums::SerialProxyMode get_mode() const { return this->mode_; } - /// Handle a mode change requested by an API client SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); @@ -167,13 +161,21 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Write bytes originating from the tap rather than from a client. Bypasses the /// subscriber ownership check, but only while the tap is being served bytes -- so a - /// port in RAW mode with a subscriber attached stays inert. - void write_from_tap(const uint8_t *data, size_t len) { - if (this->tap_observing_()) { - this->write_array(data, len); + /// port in RAW mode with a subscriber attached stays inert. Returns false when the + /// bytes were dropped for that reason. + bool write_from_tap(const uint8_t *data, size_t len) { + if (!this->tap_observing_()) { + return false; } + this->write_array(data, len); + return true; } + /// Whether the tap is currently being served bytes. Can flip false with no callback + /// (a subscriber attaching in RAW mode, say), so a tap should check before starting + /// protocol work and when a reply seems overdue. + bool tap_is_observed() const { return this->tap_observing_(); } + /// Resume reading after a tap's needs change. loop() disables itself when there is /// neither a subscriber nor a tap that wants the port, so a tap starting fresh work /// must ask for it back. Must be called from the main loop. @@ -185,7 +187,8 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the /// main loop is running -- during setup, for instance, while a component is still - /// blocking on can_proceed(). + /// blocking on can_proceed(). Must not be called from on_device_rx() or + /// on_client_tx(): each nested cycle costs a 256-byte stack frame. void tap_pump(); #endif diff --git a/tests/components/serial_proxy/common.yaml b/tests/components/serial_proxy/common.yaml index a93d22e410..6f03cf95df 100644 --- a/tests/components/serial_proxy/common.yaml +++ b/tests/components/serial_proxy/common.yaml @@ -8,4 +8,3 @@ serial_proxy: - id: serial_proxy_1 name: Test Serial Port port_type: RS232 - mode: protocol From 4437a0bd7f4704cffbf0600854139f5e4863b356 Mon Sep 17 00:00:00 2001 From: kbx81 Date: Wed, 2 Sep 2026 23:48:17 -0500 Subject: [PATCH 08/12] [serial_proxy] Reset the mode when a subscription is taken over A client taking over from a crashed subscriber inherited that session's mode; end the dead session with reset_mode_() before handing over the port, matching every other subscriber-change path. --- esphome/components/serial_proxy/serial_proxy.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 0877de66c4..a79edc4e2c 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -372,6 +372,10 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + // End the dead client's session before starting the new one, so its mode + // cannot leak into a session that never asked for it + this->api_connection_ = nullptr; + this->reset_mode_(); } this->api_connection_ = api_connection; this->enable_loop(); From 02249296249e2a602ce33908d4bfd54a1e33a2b1 Mon Sep 17 00:00:00 2001 From: kbx81 Date: Thu, 3 Sep 2026 00:10:19 -0500 Subject: [PATCH 09/12] [serial_proxy] Require an active subscription for every port operation Writes, configure, modem pins and flush previously passed for any authenticated client while nobody held the port. With a tap attached that allowed an unsubscribed writer to share the wire with the tap, with no way to select RAW to stop it (set_mode already refuses non-subscribers). All port operations now require being the live subscriber, and the proto comments state the precondition. Also fold the ifdef-inside-if in set_mode_from_client into a has_tap local for readability. --- esphome/components/api/api.proto | 12 ++++-- .../components/serial_proxy/serial_proxy.cpp | 41 +++++++++---------- .../components/serial_proxy/serial_proxy.h | 6 ++- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c6b9d8690f..21972decad 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2727,7 +2727,8 @@ enum SerialProxyParity { SERIAL_PROXY_PARITY_ODD = 2; } -// Configure UART parameters for a serial proxy instance +// Configure UART parameters for a serial proxy instance. Only the subscribed client may +// configure the port; others are refused with PORT_IN_USE (since API 1.17). message SerialProxyConfigureRequest { option (id) = 138; option (source) = SOURCE_CLIENT; @@ -2753,7 +2754,8 @@ message SerialProxyDataReceived { bytes data = 2; // Raw data received from the serial device } -// Write data to a serial device +// Write data to a serial device. Only the subscribed client may write; writes from +// others are ignored (since API 1.17). message SerialProxyWriteRequest { option (id) = 140; option (source) = SOURCE_CLIENT; @@ -2764,7 +2766,8 @@ message SerialProxyWriteRequest { bytes data = 2; // Raw data to write to the serial device } -// Set modem control pin states (RTS and DTR) +// Set modem control pin states (RTS and DTR). Only the subscribed client may set them; +// others are refused with PORT_IN_USE (since API 1.17). message SerialProxySetModemPinsRequest { option (id) = 141; option (source) = SOURCE_CLIENT; @@ -2816,7 +2819,8 @@ enum SerialProxyStatus { SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value } -// Generic request message for simple serial proxy operations +// Generic request message for simple serial proxy operations. FLUSH requires an active +// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17). message SerialProxyRequest { option (id) = 144; option (source) = SOURCE_CLIENT; diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index a79edc4e2c..a4df90b5a4 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -164,8 +164,9 @@ void SerialProxy::dump_config() { SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size) { #ifdef USE_API - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring configure request from client without port subscription [%" PRIu32 "]", + this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -235,7 +236,7 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn api::enums::SerialProxyMode mode) { #ifdef USE_API // Only the live subscriber may change the mode, so the mode cannot outlive a session - if (this->api_connection_ != api_connection) { + if (!this->is_subscriber_(api_connection)) { ESP_LOGW(TAG, "Ignoring mode request from client without port subscription [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } @@ -246,14 +247,14 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT; } // PROTOCOL on a port with no tap would be a silent no-op; refuse so the client knows - if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL) { #ifdef USE_SERIAL_PROXY_TAP - if (this->tap_ == nullptr) + const bool has_tap = this->tap_ != nullptr; +#else + const bool has_tap = false; #endif - { - ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_); - return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED; - } + if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL && !has_tap) { + ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED; } ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); @@ -274,10 +275,10 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) { #ifdef USE_API - // Bytes from a client other than the live subscriber would interleave with the - // subscriber's traffic on the wire - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_); + // Bytes from anyone but the live subscriber would interleave with the subscriber's + // traffic -- or with an active tap's -- on the wire + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_); return; } #endif @@ -295,8 +296,9 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { #ifdef USE_API - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring modem pin request from client without port subscription [%" PRIu32 "]", + this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -330,8 +332,8 @@ uint32_t SerialProxy::get_modem_pins() const { SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { #ifdef USE_API // Flushing stalls the port, so it gets the same ownership check as writes - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring flush from client without port subscription [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -350,11 +352,6 @@ SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { } #ifdef USE_API -bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const { - return this->api_connection_ != nullptr && this->api_connection_ != api_connection && - this->api_connection_->is_connection_setup(); -} - SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) { switch (type) { diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 2b20eef05e..e7b28c0221 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -198,8 +198,10 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// (slow path with a 256-byte stack buffer) void read_and_send_(size_t available); - /// True when a live subscriber other than the given connection holds the port - bool port_claimed_by_other_(api::APIConnection *api_connection) const; + /// True when the given connection is the live subscriber. Every port operation + /// (write, configure, modem pins, flush, mode) requires this, so an unsubscribed + /// client can never share the wire with the subscriber or an active tap. + bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } #endif /// Return the port to RAW when a subscriber goes away, so the mode never outlives it. From 29b5935a225f91329aa53d0fdeb80d6d2336cd09 Mon Sep 17 00:00:00 2001 From: kbx81 Date: Thu, 3 Sep 2026 00:35:44 -0500 Subject: [PATCH 10/12] [serial_proxy] Split refused-write logging by cause Writes are the only high-rate, unacknowledged operation, so a legacy client streaming without a subscription would flood WARN one line per request. Contention (another client holds the port) stays WARN; the never-subscribed case logs at VERBOSE. One-shot operations keep WARN in both cases since their request/ack pattern bounds the rate. --- esphome/components/serial_proxy/serial_proxy.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index a4df90b5a4..e69bbfb31c 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -278,7 +278,14 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui // Bytes from anyone but the live subscriber would interleave with the subscriber's // traffic -- or with an active tap's -- on the wire if (!this->is_subscriber_(api_connection)) { - ESP_LOGW(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_); + if (this->api_connection_ != nullptr) { + ESP_LOGW(TAG, "Ignoring write from client that does not hold serial proxy [%" PRIu32 "]", this->instance_index_); + } else { + // A legacy client streaming writes without subscribing would flood WARN, one per + // request; writes are the only high-rate, unacknowledged operation, so keep this + // visible without drowning the log + ESP_LOGV(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_); + } return; } #endif From 82675a2c78908ac8baf6d98a7967ddee4e90419a Mon Sep 17 00:00:00 2001 From: kbx81 Date: Thu, 3 Sep 2026 00:52:17 -0500 Subject: [PATCH 11/12] [serial_proxy] Guard leaving_protocol_mode with the tap define Its only reader is tap-gated, so non-tap builds warned about an unused variable. --- esphome/components/serial_proxy/serial_proxy.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index e69bbfb31c..416deddc92 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -258,8 +258,10 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn } ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); +#ifdef USE_SERIAL_PROXY_TAP const bool leaving_protocol_mode = this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; +#endif this->mode_ = mode; #ifdef USE_SERIAL_PROXY_TAP From c432ab146fad2cab46b21c698d29d00cdb58a83b Mon Sep 17 00:00:00 2001 From: kbx81 Date: Thu, 3 Sep 2026 01:16:55 -0500 Subject: [PATCH 12/12] [serial_proxy] Compile out mode state in builds without a tap PROTOCOL is refused when no tap exists, so mode_ could never leave RAW there; gate the member and reset_mode_() behind USE_SERIAL_PROXY_TAP (no-op inline otherwise), saving the member and the four reset calls in every tapless build. --- esphome/components/serial_proxy/serial_proxy.cpp | 4 ++-- esphome/components/serial_proxy/serial_proxy.h | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 416deddc92..129745c1c9 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -43,6 +43,7 @@ void SerialProxy::setup() { this->disable_loop(); } +#ifdef USE_SERIAL_PROXY_TAP void SerialProxy::reset_mode_() { // The mode belongs to a session, not to the port. Carrying a departed client's choice // over to the next one would inject protocol bytes into a stream that never asked for @@ -55,6 +56,7 @@ void SerialProxy::reset_mode_() { ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_); this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW; } +#endif void SerialProxy::loop() { #ifdef USE_API @@ -261,10 +263,8 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn #ifdef USE_SERIAL_PROXY_TAP const bool leaving_protocol_mode = this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; -#endif this->mode_ = mode; -#ifdef USE_SERIAL_PROXY_TAP // Only for an explicit client request, not for reset_mode_() at the end of a session: // an ordinary disconnect says nothing about the device, whereas a client deliberately // asking for raw bytes usually precedes changing what the device is. diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index e7b28c0221..e3f4264cfa 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -204,9 +204,14 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } #endif - /// Return the port to RAW when a subscriber goes away, so the mode never outlives it. - /// Not tap-gated: the mode is a client-visible property whether or not a tap acts on it. +#ifdef USE_SERIAL_PROXY_TAP + /// Return the port to RAW when a subscriber goes away, so the mode never outlives it void reset_mode_(); +#else + /// Without a tap, PROTOCOL is refused, so the mode is fixed at RAW and there is + /// nothing to reset + void reset_mode_() {} +#endif #ifdef USE_SERIAL_PROXY_TAP /// True when the tap should be shown the traffic passing through this port @@ -230,8 +235,10 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Port type api::enums::SerialProxyPortType port_type_{}; +#ifdef USE_SERIAL_PROXY_TAP /// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW api::enums::SerialProxyMode mode_{}; +#endif /// Optional GPIO pins for modem control GPIOPin *rts_pin_{nullptr};