diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index fb2d473fb9..65d4159403 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -326,8 +326,10 @@ class APIConnection final : public APIServerConnectionBase { bool is_marked_for_removal() const { return this->flags_.remove; } uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; } - // Get client API version for feature detection - bool client_supports_api_version(uint16_t major, uint16_t minor) const { + // Get client API version for feature detection. + // Stored versions saturate at 255 (see send_hello_response_), so requesting + // a minimum above that can never match. + bool client_supports_api_version(uint8_t major, uint8_t minor) const { return this->client_api_version_major_ > major || (this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor); } diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index e024cdf5db..09ace7294a 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -5,6 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "api_pb2.h" #include "proto.h" #include #include @@ -252,6 +253,13 @@ ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_ *p = static_cast(value); } +// The generator rejects message IDs above MAX_MESSAGE_TYPE, so the type varint +// can never outgrow the 2 bytes HEADER_PADDING budgets for it. Without this +// bound, write_plaintext_header's header_offset would underflow for the first +// message in a batch and the header write would land outside the buffer. +static_assert(1 + 3 + ProtoSize::varint16(MAX_MESSAGE_TYPE) <= APIPlaintextFrameHelper::HEADER_PADDING, + "HEADER_PADDING cannot fit the type varint of the largest message ID"); + // Write plaintext header into pre-allocated padding before payload. // padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg, // actual header size for contiguous batch messages). diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5fbd3ebb3e..0585a8928f 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -9,6 +9,10 @@ namespace esphome::api { +// Upper bound on message IDs, enforced by the code generator: the plaintext +// frame header budgets 2 varint bytes for the type (HEADER_PADDING). +static constexpr uint16_t MAX_MESSAGE_TYPE = 16383; + namespace enums { enum DisconnectReason : uint32_t { diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 529431c292..93ab4eeee4 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -475,6 +475,19 @@ TYPE_INFO: dict[int, TypeInfo] = {} # TYPE_DOUBLE = 1, TYPE_FIXED64 = 6, TYPE_SFIXED64 = 16, TYPE_SINT64 = 18 UNSUPPORTED_TYPES = {1: "double", 6: "fixed64", 16: "sfixed64", 18: "sint64"} +# The plaintext frame header budgets 2 varint bytes for the message type +# (APIPlaintextFrameHelper::HEADER_PADDING), which caps message IDs at 16383. +MAX_MESSAGE_ID = 16383 + + +def validate_message_id(message_id: int, message_name: str) -> None: + """Reject message IDs whose plaintext type varint would not fit in 2 bytes.""" + if message_id > MAX_MESSAGE_ID: + raise ValueError( + f"Message ID {message_id} for {message_name} exceeds the plaintext " + f"2-byte type varint maximum ({MAX_MESSAGE_ID})" + ) + def validate_field_type(field_type: int, field_name: str = "") -> None: """Validate that the field type is supported by ESPHome API. @@ -2511,13 +2524,7 @@ def build_message_type( # Add MESSAGE_TYPE method if this is a service message if message_id is not None: - # The plaintext frame header budgets 2 varint bytes for the type - # (HEADER_PADDING), which caps message IDs at 16383 - if message_id > 16383: - raise ValueError( - f"Message ID {message_id} for {desc.name} exceeds the plaintext " - "2-byte type varint maximum (16383)" - ) + validate_message_id(message_id, desc.name) # Add static constexpr for message type public_content.append(f"static constexpr uint16_t MESSAGE_TYPE = {message_id};") @@ -3176,8 +3183,12 @@ def main() -> None: #include "api_pb2_includes.h" """ - content += """ -namespace esphome::api { + content += f""" +namespace esphome::api {{ + +// Upper bound on message IDs, enforced by the code generator: the plaintext +// frame header budgets 2 varint bytes for the type (HEADER_PADDING). +static constexpr uint16_t MAX_MESSAGE_TYPE = {MAX_MESSAGE_ID}; """ diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 2a07cbd49c..797125ba8f 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -15,7 +15,12 @@ import pytest sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf")) -from api_protobuf import _make_ifdef_line, get_varint64_ifdef # noqa: E402 +from api_protobuf import ( # noqa: E402 + MAX_MESSAGE_ID, + _make_ifdef_line, + get_varint64_ifdef, + validate_message_id, +) from google.protobuf import descriptor_pb2 # noqa: E402 @@ -91,3 +96,14 @@ def test_make_ifdef_line_conjunction_and_negation() -> None: assert ( _make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)" ) + + +def test_message_id_at_maximum_is_accepted() -> None: + # 16383 is the largest ID whose plaintext type varint fits the 2 bytes + # budgeted in HEADER_PADDING. + validate_message_id(MAX_MESSAGE_ID, "MaxMessage") + + +def test_message_id_above_maximum_is_rejected() -> None: + with pytest.raises(ValueError, match="exceeds the plaintext"): + validate_message_id(MAX_MESSAGE_ID + 1, "TooBigMessage")