From 9bc81b11fa05859c0e818d465b314a111a0c734d Mon Sep 17 00:00:00 2001 From: kbx81 Date: Sat, 15 Aug 2026 02:34:08 -0500 Subject: [PATCH] Advertise configured modem pins, enforce response-only types, ack get_modem_pins errors --- esphome/components/api/api.proto | 5 ++++- esphome/components/api/api_connection.cpp | 19 ++++++++++++++----- esphome/components/api/api_pb2.cpp | 4 ++++ esphome/components/api/api_pb2.h | 4 +++- esphome/components/api/api_pb2_dump.cpp | 2 ++ .../components/serial_proxy/serial_proxy.cpp | 8 +++----- .../components/serial_proxy/serial_proxy.h | 6 ++++++ .../components/api/test_api_proto.py | 11 +++++++++++ 8 files changed, 47 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c2acd52332..d43bbe1dd2 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -232,6 +232,7 @@ enum SerialProxyPortType { message SerialProxyInfo { string name = 1; // Human-readable port name SerialProxyPortType port_type = 2; // Port type (RS232, RS485) + uint32 configured_line_states = 3; // Bitmask of SerialProxyLineStateFlags this instance can drive } // DeviceInfoResponse max_data_length values: @@ -2781,6 +2782,7 @@ message SerialProxyGetModemPinsResponse { uint32 instance = 1; // Instance index (0-based) uint32 line_states = 2; // Bitmask of SerialProxyLineStateFlags + SerialProxyStatus status = 3; // INVALID_ARGUMENT if the instance index is out of range (since API 1.16) } enum SerialProxyRequestType { @@ -2788,7 +2790,8 @@ enum SerialProxyRequestType { SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; // Unsubscribe from this serial proxy instance SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2; // Flush the serial port (block until all TX data is sent) // Values below are only valid in SerialProxyRequestResponse.type, identifying which - // operation is being acknowledged; they must not be sent in SerialProxyRequest.type. + // operation is being acknowledged. Sending them in SerialProxyRequest.type is an + // 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 } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 51c9221b77..e49dd677e9 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1628,13 +1628,14 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM 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 %" PRIu32 " out of range", msg.instance); - return; - } SerialProxyGetModemPinsResponse resp{}; resp.instance = msg.instance; - resp.line_states = proxies[msg.instance]->get_modem_pins(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT; + } else { + resp.line_states = proxies[msg.instance]->get_modem_pins(); + } if (!this->send_message(resp)) { API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); } @@ -1657,6 +1658,12 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: status = serial_proxy_result_to_status(proxy->flush_port(this)); break; + case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE: + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: + // 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; + break; default: ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast(msg.type)); status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED; @@ -1923,6 +1930,7 @@ bool APIConnection::send_device_info_response_() { auto &info = resp.serial_proxies[serial_proxy_index++]; info.name = StringRef(proxy->get_name()); info.port_type = proxy->get_port_type(); + info.configured_line_states = proxy->get_configured_modem_pins(); } #endif #ifdef USE_API_NOISE @@ -1983,6 +1991,7 @@ bool APIConnection::send_device_capabilities_response_() { auto &info = resp.serial_proxies[serial_proxy_index++]; info.name = StringRef(proxy->get_name()); info.port_type = proxy->get_port_type(); + info.configured_line_states = proxy->get_configured_modem_pins(); } #endif return this->send_message(resp); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 9203c3d508..77c6fdf9c0 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -102,12 +102,14 @@ uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PAR uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->port_type)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states); return pos; } uint32_t SerialProxyInfo::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->name.size()); size += this->port_type ? 2 : 0; + size += ProtoSize::calc_uint32(1, this->configured_line_states); return size; } #endif @@ -4199,12 +4201,14 @@ uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_ uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); return pos; } uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->instance); size += ProtoSize::calc_uint32(1, this->line_states); + size += this->status ? 2 : 0; return size; } bool SerialProxyRequest::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 06de1a439f..f29f925070 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -532,6 +532,7 @@ class SerialProxyInfo final : public ProtoMessage { public: StringRef name{}; enums::SerialProxyPortType port_type{}; + uint32_t configured_line_states{0}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -3340,12 +3341,13 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { class SerialProxyGetModemPinsResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 143; - static constexpr uint8_t ESTIMATED_SIZE = 8; + static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_response"); } #endif uint32_t instance{0}; uint32_t line_states{0}; + enums::SerialProxyStatus status{}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 44a1dde1f5..d344e8a057 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -934,6 +934,7 @@ const char *SerialProxyInfo::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyInfo")); dump_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("port_type"), static_cast(this->port_type)); + dump_field(out, ESPHOME_PSTR("configured_line_states"), this->configured_line_states); return out.c_str(); } #endif @@ -2779,6 +2780,7 @@ const char *SerialProxyGetModemPinsResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetModemPinsResponse")); dump_field(out, ESPHOME_PSTR("instance"), this->instance); dump_field(out, ESPHOME_PSTR("line_states"), this->line_states); + dump_field(out, ESPHOME_PSTR("status"), static_cast(this->status)); return out.c_str(); } const char *SerialProxyRequest::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index d5562f761b..94cefc8700 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -172,11 +172,9 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection } #endif // Asserting a pin that is not configured must fail so the client learns the signal never - // reached the wire; deasserting an absent pin is harmless and stays allowed - const uint32_t configured = - (this->rts_pin_ != nullptr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | - (this->dtr_pin_ != nullptr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); - if ((line_states & ~configured) != 0) { + // reached the wire; deasserting an absent pin is harmless and stays allowed. Clients can + // avoid this by masking against SerialProxyInfo.configured_line_states. + if ((line_states & ~this->get_configured_modem_pins()) != 0) { ESP_LOGW(TAG, "Requested modem pin not configured on serial proxy [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED; } diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index f1fde3071f..a0e47ee686 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -105,6 +105,12 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get current modem pin states as a bitmask of SerialProxyLineStateFlag values uint32_t get_modem_pins() const; + /// Get the modem pins this instance can drive as a bitmask of SerialProxyLineStateFlag values + uint32_t get_configured_modem_pins() const { + return (this->rts_pin_ != nullptr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | + (this->dtr_pin_ != nullptr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); + } + /// Flush the serial port (block until all TX data is sent) /// @param api_connection The API connection requesting the flush SerialProxyResult flush_port(api::APIConnection *api_connection); diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index 35aa5ff529..31297911f5 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -263,6 +263,17 @@ def test_device_capabilities_response_has_id_150() -> None: ) +def test_z_wave_proxy_request_response_has_id_151() -> None: + body = _extract_proto_message(PROTO_TEXT, "ZWaveProxyRequestResponse") + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, "ZWaveProxyRequestResponse is missing `option (id)`" + assert int(match.group(1)) == 151, ( + f"ZWaveProxyRequestResponse has id {match.group(1)}, expected 151. " + "Message ids are part of the wire protocol and must not change once " + "assigned." + ) + + def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None: """The six superseded fields must not carry `[deprecated = true]` in api.proto, or the generator drops them and old clients stop receiving