From 95974358ab718afb2fd97014ff8c138ce3d4280b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Apr 2026 18:03:26 -1000 Subject: [PATCH 1/5] [api] Add max_data_length and force to DeviceInfoResponse/HelloResponse proto fields Add max_data_length + force proto options to string fields in HelloResponse, AreaInfo, DeviceInfo, and DeviceInfoResponse where we have strong compile-time or codegen guarantees on max length. This enables encode_short_string_force() (direct byte writes, no varint branching) for fields with single-byte tags, and fixed-formula size calculations for all annotated fields. Add static_asserts to cross-validate C++ constexpr constants (MAC_ADDRESS_PRETTY_BUFFER_SIZE, BUILD_TIME_STR_SIZE, ESPHOME_VERSION, ESPHOME_MANUFACTURER) against the proto max_data_length annotations. --- esphome/components/api/api.proto | 33 ++++++++++------- esphome/components/api/api_connection.cpp | 7 ++++ esphome/components/api/api_pb2.cpp | 44 +++++++++++------------ 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 07705baff6..8e3b5a1ffb 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -129,11 +129,12 @@ message HelloResponse { // A string identifying the server (ESP); like client info this may be empty // and only exists for debugging/logging purposes. - // For example "ESPHome v1.10.0 on ESP8266" - string server_info = 3; + // Currently set to ESPHOME_VERSION string literal. + string server_info = 3 [(max_data_length) = 32, (force) = true]; // The name of the server (App.get_name()) - string name = 4; + // max_data_length matches config_validation.NAME_MAX_LENGTH + string name = 4 [(max_data_length) = 120, (force) = true]; } // DEPRECATED in ESPHome 2026.1.0 - Password authentication is no longer supported. @@ -196,12 +197,14 @@ message DeviceInfoRequest { message AreaInfo { uint32 area_id = 1; - string name = 2; + // max_data_length matches core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA + string name = 2 [(max_data_length) = 120, (force) = true]; } message DeviceInfo { uint32 device_id = 1; - string name = 2; + // max_data_length matches core/config.FRIENDLY_NAME_MAX_LEN via DEVICE_SCHEMA + string name = 2 [(max_data_length) = 120, (force) = true]; uint32 area_id = 3; } @@ -216,6 +219,12 @@ message SerialProxyInfo { SerialProxyPortType port_type = 2; // Port type (RS232, RS485) } +// DeviceInfoResponse max_data_length values: +// name/friendly_name = 120 (config_validation.NAME_MAX_LENGTH / core/config.FRIENDLY_NAME_MAX_LEN) +// mac_address/bluetooth_mac_address = 17 (MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1, constexpr) +// esphome_version = 32 (ESPHOME_VERSION string literal) +// compilation_time = 25 (Application::BUILD_TIME_STR_SIZE - 1, constexpr) +// manufacturer = 20 (longest hardcoded literal: "Nordic Semiconductor") message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -225,18 +234,18 @@ message DeviceInfoResponse { bool uses_password = 1 [deprecated = true]; // The name of the node, given by "App.set_name()" - string name = 2; + string name = 2 [(max_data_length) = 120, (force) = true]; // The mac address of the device. For example "AC:BC:32:89:0E:A9" - string mac_address = 3; + string mac_address = 3 [(max_data_length) = 17, (force) = true]; // A string describing the ESPHome version. For example "1.10.0" - string esphome_version = 4; + string esphome_version = 4 [(max_data_length) = 32, (force) = true]; // A string describing the date of compilation, this is generated by the compiler // and therefore may not be in the same format all the time. // If the user isn't using ESPHome, this will also not be set. - string compilation_time = 5; + string compilation_time = 5 [(max_data_length) = 25, (force) = true]; // The model of the board. For example NodeMCU string model = 6; @@ -253,9 +262,9 @@ message DeviceInfoResponse { uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; - string manufacturer = 12; + string manufacturer = 12 [(max_data_length) = 20, (force) = true]; - string friendly_name = 13; + string friendly_name = 13 [(max_data_length) = 120, (force) = true]; // Deprecated in API version 1.10 uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"]; @@ -264,7 +273,7 @@ message DeviceInfoResponse { string suggested_area = 16 [(field_ifdef) = "USE_AREAS"]; // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" - string bluetooth_mac_address = 18 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; + string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; // Supports receiving and saving api encryption key bool api_encryption_supported = 19 [(field_ifdef) = "USE_API_NOISE"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index feb16e4f4c..53c4aabcb7 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -72,6 +72,12 @@ static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 60000; static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); +// Cross-validate C++ constants against proto max_data_length annotations in api.proto +static_assert(MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1 == 17, + "Update max_data_length for mac_address/bluetooth_mac_address in api.proto"); +static_assert(Application::BUILD_TIME_STR_SIZE - 1 == 25, "Update max_data_length for compilation_time in api.proto"); +static_assert(sizeof(ESPHOME_VERSION) - 1 <= 32, "Update max_data_length for esphome_version in api.proto"); + static const char *const TAG = "api.connection"; #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; @@ -1716,6 +1722,7 @@ bool APIConnection::send_device_info_response_() { static constexpr auto MANUFACTURER = StringRef::from_lit(ESPHOME_MANUFACTURER); resp.manufacturer = MANUFACTURER; #endif + static_assert(sizeof(ESPHOME_MANUFACTURER) - 1 <= 20, "Update max_data_length for manufacturer in api.proto"); #undef ESPHOME_MANUFACTURER #ifdef USE_ESP8266 diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f7c68b95a7..66996452fa 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -35,29 +35,29 @@ uint8_t *HelloResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->api_version_major); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->api_version_minor); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->server_info); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->server_info); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->name); return pos; } uint32_t HelloResponse::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->api_version_major); size += ProtoSize::calc_uint32(1, this->api_version_minor); - size += ProtoSize::calc_length(1, this->server_info.size()); - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->server_info.size(); + size += 2 + this->name.size(); return size; } #ifdef USE_AREAS uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->area_id); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); return pos; } uint32_t AreaInfo::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->area_id); - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); return size; } #endif @@ -65,14 +65,14 @@ uint32_t AreaInfo::calculate_size() const { uint8_t *DeviceInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->area_id); return pos; } uint32_t DeviceInfo::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->device_id); - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); size += ProtoSize::calc_uint32(1, this->area_id); return size; } @@ -93,10 +93,10 @@ uint32_t SerialProxyInfo::calculate_size() const { #endif uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->name); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->mac_address); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->esphome_version); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->compilation_time); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->mac_address); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->esphome_version); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, this->compilation_time); ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->model); #ifdef USE_DEEP_SLEEP ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->has_deep_sleep); @@ -113,8 +113,8 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ #ifdef USE_BLUETOOTH_PROXY ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, this->bluetooth_proxy_feature_flags); #endif - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 12, this->manufacturer); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->friendly_name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, this->manufacturer); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, this->friendly_name); #ifdef USE_VOICE_ASSISTANT ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, this->voice_assistant_feature_flags); #endif @@ -122,7 +122,7 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address, true); #endif #ifdef USE_API_NOISE ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, this->api_encryption_supported); @@ -155,10 +155,10 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ } uint32_t DeviceInfoResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->name.size()); - size += ProtoSize::calc_length(1, this->mac_address.size()); - size += ProtoSize::calc_length(1, this->esphome_version.size()); - size += ProtoSize::calc_length(1, this->compilation_time.size()); + size += 2 + this->name.size(); + size += 2 + this->mac_address.size(); + size += 2 + this->esphome_version.size(); + size += 2 + this->compilation_time.size(); size += ProtoSize::calc_length(1, this->model.size()); #ifdef USE_DEEP_SLEEP size += ProtoSize::calc_bool(1, this->has_deep_sleep); @@ -175,8 +175,8 @@ uint32_t DeviceInfoResponse::calculate_size() const { #ifdef USE_BLUETOOTH_PROXY size += ProtoSize::calc_uint32(1, this->bluetooth_proxy_feature_flags); #endif - size += ProtoSize::calc_length(1, this->manufacturer.size()); - size += ProtoSize::calc_length(1, this->friendly_name.size()); + size += 2 + this->manufacturer.size(); + size += 2 + this->friendly_name.size(); #ifdef USE_VOICE_ASSISTANT size += ProtoSize::calc_uint32(2, this->voice_assistant_feature_flags); #endif @@ -184,7 +184,7 @@ uint32_t DeviceInfoResponse::calculate_size() const { size += ProtoSize::calc_length(2, this->suggested_area.size()); #endif #ifdef USE_BLUETOOTH_PROXY - size += ProtoSize::calc_length(2, this->bluetooth_mac_address.size()); + size += 3 + this->bluetooth_mac_address.size(); #endif #ifdef USE_API_NOISE size += ProtoSize::calc_bool(2, this->api_encryption_supported); From 5a92a3b16e62967b06a24db903078b56b72d6826 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Apr 2026 18:14:18 -1000 Subject: [PATCH 2/5] [api] Add max_data_length/force to model and project fields, validate board/project lengths Add BOARD_MAX_LENGTH and PROJECT_MAX_LENGTH constants (127) and enforce them in YAML validation for all platform board schemas and project name/version. Add max_data_length + force proto options to model, project_name, and project_version fields in DeviceInfoResponse. --- esphome/components/api/api.proto | 10 +++++++--- esphome/components/api/api_pb2.cpp | 12 ++++++------ esphome/components/esp32/__init__.py | 5 ++++- esphome/components/esp8266/__init__.py | 5 ++++- esphome/components/libretiny/__init__.py | 5 ++++- esphome/components/nrf52/__init__.py | 5 ++++- esphome/components/rp2040/__init__.py | 5 ++++- esphome/core/config.py | 14 ++++++++++++-- 8 files changed, 45 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 8e3b5a1ffb..0a49a76c11 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -225,6 +225,8 @@ message SerialProxyInfo { // esphome_version = 32 (ESPHOME_VERSION string literal) // compilation_time = 25 (Application::BUILD_TIME_STR_SIZE - 1, constexpr) // manufacturer = 20 (longest hardcoded literal: "Nordic Semiconductor") +// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas) +// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH) message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -248,13 +250,15 @@ message DeviceInfoResponse { string compilation_time = 5 [(max_data_length) = 25, (force) = true]; // The model of the board. For example NodeMCU - string model = 6; + // max_data_length matches core/config.BOARD_MAX_LENGTH (validated in platform schemas) + string model = 6 [(max_data_length) = 127, (force) = true]; bool has_deep_sleep = 7 [(field_ifdef) = "USE_DEEP_SLEEP"]; // The esphome project details if set - string project_name = 8 [(field_ifdef) = "ESPHOME_PROJECT_NAME"]; - string project_version = 9 [(field_ifdef) = "ESPHOME_PROJECT_NAME"]; + // max_data_length matches core/config.PROJECT_MAX_LENGTH + string project_name = 8 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"]; + string project_version = 9 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"]; uint32 webserver_port = 10 [(field_ifdef) = "USE_WEBSERVER"]; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 66996452fa..64adc8bc9e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -97,15 +97,15 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->mac_address); ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->esphome_version); ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, this->compilation_time); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->model); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, this->model); #ifdef USE_DEEP_SLEEP ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->project_name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, this->project_name); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->project_version); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, this->project_version); #endif #ifdef USE_WEBSERVER ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->webserver_port); @@ -159,15 +159,15 @@ uint32_t DeviceInfoResponse::calculate_size() const { size += 2 + this->mac_address.size(); size += 2 + this->esphome_version.size(); size += 2 + this->compilation_time.size(); - size += ProtoSize::calc_length(1, this->model.size()); + size += 2 + this->model.size(); #ifdef USE_DEEP_SLEEP size += ProtoSize::calc_bool(1, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - size += ProtoSize::calc_length(1, this->project_name.size()); + size += 2 + this->project_name.size(); #endif #ifdef ESPHOME_PROJECT_NAME - size += ProtoSize::calc_length(1, this->project_version.size()); + size += 2 + this->project_version.size(); #endif #ifdef USE_WEBSERVER size += ProtoSize::calc_uint32(1, this->webserver_port); diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0d8a221524..54eecb5ad1 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -44,6 +44,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE, HexInt +from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed @@ -1403,7 +1404,9 @@ CONF_PARTITIONS = "partitions" CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_BOARD): cv.string_strict, + cv.Optional(CONF_BOARD): cv.All( + cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_CPU_FREQUENCY): cv.one_of( *FULL_CPU_FREQUENCIES, upper=True ), diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index fcd3499b15..76c8b1e870 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -20,6 +20,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed from esphome.types import ConfigType @@ -203,7 +204,9 @@ BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"] CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_RESTORE_FROM_FLASH, default=False): cv.boolean, cv.Optional(CONF_EARLY_PIN_INIT, default=True): cv.boolean, diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 8f99124604..00be620831 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -23,6 +23,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE +from esphome.core.config import BOARD_MAX_LENGTH from esphome.storage_json import StorageJSON from . import gpio # noqa @@ -266,7 +267,9 @@ CONFIG_SCHEMA = cv.All(_notify_old_style) BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(LTComponent), - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_FAMILY): cv.one_of(*FAMILIES, upper=True), cv.Optional(CONF_FRAMEWORK, default={}): FRAMEWORK_SCHEMA, }, diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 5054e5e0df..566aefb209 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -46,6 +46,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.core.config import BOARD_MAX_LENGTH import esphome.final_validate as fv from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -145,7 +146,9 @@ CONFIG_SCHEMA = cv.All( set_core_data, cv.Schema( { - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH) + ), cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), cv.Optional(CONF_DFU): cv.Schema( { diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 0bb1811069..628cdf370a 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -22,6 +22,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed from . import boards @@ -168,7 +169,9 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="8388ms"): cv.All( cv.positive_time_period_milliseconds, diff --git a/esphome/core/config.py b/esphome/core/config.py index 31cfd00ef7..efc6d62f48 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -236,6 +236,12 @@ ICON_MAX_LENGTH = 63 # Max unit of measurement string length UNIT_OF_MEASUREMENT_MAX_LENGTH = 63 +# Max project name/version string length (must fit in single-byte varint for proto encoding) +PROJECT_MAX_LENGTH = 127 + +# Max board/model string length (must fit in single-byte varint for proto encoding) +BOARD_MAX_LENGTH = 127 + AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), @@ -306,9 +312,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PROJECT): cv.Schema( { cv.Required(CONF_NAME): cv.All( - cv.string_strict, valid_project_name + cv.string_strict, + valid_project_name, + cv.Length(max=PROJECT_MAX_LENGTH), + ), + cv.Required(CONF_VERSION): cv.All( + cv.string_strict, cv.Length(max=PROJECT_MAX_LENGTH) ), - cv.Required(CONF_VERSION): cv.string_strict, cv.Optional(CONF_ON_UPDATE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( From 52d76f8db09bd3f60140c801bb4e2e2c8eb8b813 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Apr 2026 18:17:04 -1000 Subject: [PATCH 3/5] [api] Fix name max_data_length to match hostname limit, add suggested_area - Fix HelloResponse.name and DeviceInfoResponse.name max_data_length from 120 to 31 to match ESPHOME_DEVICE_NAME_MAX_LEN (hostname limit) - Add max_data_length=120 + force to suggested_area field - Add static_asserts for ESPHOME_DEVICE_NAME_MAX_LEN and ESPHOME_FRIENDLY_NAME_MAX_LEN - Fix comments to accurately describe guarantees --- esphome/components/api/api.proto | 16 +++++++++------- esphome/components/api/api_connection.cpp | 2 ++ esphome/components/api/api_pb2.cpp | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 0a49a76c11..33d16f0339 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -132,9 +132,9 @@ message HelloResponse { // Currently set to ESPHOME_VERSION string literal. string server_info = 3 [(max_data_length) = 32, (force) = true]; - // The name of the server (App.get_name()) - // max_data_length matches config_validation.NAME_MAX_LENGTH - string name = 4 [(max_data_length) = 120, (force) = true]; + // The name of the server (App.get_name() - device hostname) + // max_data_length matches ESPHOME_DEVICE_NAME_MAX_LEN (validated by validate_hostname) + string name = 4 [(max_data_length) = 31, (force) = true]; } // DEPRECATED in ESPHome 2026.1.0 - Password authentication is no longer supported. @@ -220,13 +220,15 @@ message SerialProxyInfo { } // DeviceInfoResponse max_data_length values: -// name/friendly_name = 120 (config_validation.NAME_MAX_LENGTH / core/config.FRIENDLY_NAME_MAX_LEN) +// name = 31 (ESPHOME_DEVICE_NAME_MAX_LEN, validated by validate_hostname) +// friendly_name = 120 (core/config.FRIENDLY_NAME_MAX_LEN) // mac_address/bluetooth_mac_address = 17 (MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1, constexpr) // esphome_version = 32 (ESPHOME_VERSION string literal) // compilation_time = 25 (Application::BUILD_TIME_STR_SIZE - 1, constexpr) // manufacturer = 20 (longest hardcoded literal: "Nordic Semiconductor") // model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas) // project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH) +// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA) message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -235,8 +237,8 @@ message DeviceInfoResponse { // with older ESPHome versions that still send this field. bool uses_password = 1 [deprecated = true]; - // The name of the node, given by "App.set_name()" - string name = 2 [(max_data_length) = 120, (force) = true]; + // The name of the node, given by "App.set_name()" - device hostname + string name = 2 [(max_data_length) = 31, (force) = true]; // The mac address of the device. For example "AC:BC:32:89:0E:A9" string mac_address = 3 [(max_data_length) = 17, (force) = true]; @@ -274,7 +276,7 @@ message DeviceInfoResponse { uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"]; uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; - string suggested_area = 16 [(field_ifdef) = "USE_AREAS"]; + string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"]; // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 53c4aabcb7..bfb3ec291c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -77,6 +77,8 @@ static_assert(MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1 == 17, "Update max_data_length for mac_address/bluetooth_mac_address in api.proto"); static_assert(Application::BUILD_TIME_STR_SIZE - 1 == 25, "Update max_data_length for compilation_time in api.proto"); static_assert(sizeof(ESPHOME_VERSION) - 1 <= 32, "Update max_data_length for esphome_version in api.proto"); +static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for name in api.proto"); +static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); static const char *const TAG = "api.connection"; #ifdef USE_CAMERA diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 64adc8bc9e..d27cfa57cf 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -119,7 +119,7 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area, true); #endif #ifdef USE_BLUETOOTH_PROXY ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address, true); @@ -181,7 +181,7 @@ uint32_t DeviceInfoResponse::calculate_size() const { size += ProtoSize::calc_uint32(2, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - size += ProtoSize::calc_length(2, this->suggested_area.size()); + size += 3 + this->suggested_area.size(); #endif #ifdef USE_BLUETOOTH_PROXY size += 3 + this->bluetooth_mac_address.size(); From cfcf9216f3e53bd2aeae6a9291b7670bc75687b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Apr 2026 18:33:04 -1000 Subject: [PATCH 4/5] [core] Add cv.ByteLength validator, switch all proto-backed length checks to byte length Add cv.ByteLength() validator that checks UTF-8 byte length instead of character count. This ensures multibyte characters don't cause the encoded string to exceed the proto max_data_length limit. Switch all length validators that feed into proto fields with max_data_length annotations to use byte length: - Entity names, friendly names, area names, device names - Icons, device classes, units of measurement - Board names, project name/version --- esphome/components/esp32/__init__.py | 2 +- esphome/components/esp8266/__init__.py | 2 +- esphome/components/libretiny/__init__.py | 2 +- esphome/components/nrf52/__init__.py | 2 +- esphome/components/number/__init__.py | 2 +- esphome/components/rp2040/__init__.py | 2 +- esphome/components/sensor/__init__.py | 2 +- esphome/config_validation.py | 34 +++++++++++++++++++----- esphome/core/config.py | 10 +++---- esphome/core/entity_helpers.py | 15 ++++++----- 10 files changed, 49 insertions(+), 24 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 54eecb5ad1..f27690c97b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1405,7 +1405,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_BOARD): cv.All( - cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH) + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) ), cv.Optional(CONF_CPU_FREQUENCY): cv.one_of( *FULL_CPU_FREQUENCIES, upper=True diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 76c8b1e870..bef7e36470 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -205,7 +205,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_BOARD): cv.All( - cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH) + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) ), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_RESTORE_FROM_FLASH, default=False): cv.boolean, diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 00be620831..656eee6d7b 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -268,7 +268,7 @@ BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(LTComponent), cv.Required(CONF_BOARD): cv.All( - cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH) + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) ), cv.Optional(CONF_FAMILY): cv.one_of(*FAMILIES, upper=True), cv.Optional(CONF_FRAMEWORK, default={}): FRAMEWORK_SCHEMA, diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 566aefb209..5d92a4fa80 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -147,7 +147,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_BOARD): cv.All( - cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH) + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) ), cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), cv.Optional(CONF_DFU): cv.Schema( diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 26d2602ba4..1a0dc42407 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -190,7 +190,7 @@ validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") validate_unit_of_measurement = cv.All( cv.string_strict, # Keep in sync with max_data_length in api.proto - cv.Length(max=UNIT_OF_MEASUREMENT_MAX_LENGTH), + cv.ByteLength(max=UNIT_OF_MEASUREMENT_MAX_LENGTH), ) _NUMBER_SCHEMA = ( diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 628cdf370a..e452780d41 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -170,7 +170,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_BOARD): cv.All( - cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH) + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) ), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="8388ms"): cv.All( diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 275c4542fb..550e32c506 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -294,7 +294,7 @@ RoundMultipleFilter = sensor_ns.class_("RoundMultipleFilter", Filter) validate_unit_of_measurement = cv.All( cv.string_strict, # Keep in sync with max_data_length in api.proto - cv.Length(max=UNIT_OF_MEASUREMENT_MAX_LENGTH), + cv.ByteLength(max=UNIT_OF_MEASUREMENT_MAX_LENGTH), ) validate_accuracy_decimals = cv.int_ validate_icon = cv.icon diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c6b67e9f35..cbf81de147 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -130,6 +130,26 @@ RequiredFieldInvalid = vol.RequiredFieldInvalid # the rest of the error path is relative to the root config path ROOT_CONFIG_PATH = object() + +def ByteLength(*, max: int): + """Validate that the UTF-8 byte length of a string does not exceed max. + + Use instead of Length() when the limit must apply to encoded bytes, + not characters (e.g. for protobuf length-varint constraints). + """ + + def validator(value): + byte_len = len(str(value).encode("utf-8")) + if byte_len > max: + raise Invalid( + f"String is too long ({byte_len} bytes, max {max}). " + f"Multibyte characters count as multiple bytes." + ) + return value + + return validator + + RESERVED_IDS = [ # C++ keywords https://en.cppreference.com/w/cpp/keyword "alarm", @@ -411,9 +431,10 @@ def icon(value): raise Invalid( 'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"' ) - if len(value) > ICON_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) + if byte_len > ICON_MAX_LENGTH: raise Invalid( - f"Icon string is too long ({len(value)} chars, max {ICON_MAX_LENGTH}). " + f"Icon string is too long ({byte_len} bytes, max {ICON_MAX_LENGTH}). " "Icons are stored in PROGMEM with a 64-byte buffer limit." ) return value @@ -2067,11 +2088,12 @@ def _validate_entity_name(value): "Name cannot be None when esphome->friendly_name is not set!" )(value) if value is not None: - # Validate length for web server URL compatibility - if len(value) > NAME_MAX_LENGTH: + # Validate byte length for web server URL and proto encoding compatibility + byte_len = len(value.encode("utf-8")) + if byte_len > NAME_MAX_LENGTH: raise Invalid( - f"Name is too long ({len(value)} chars). " - f"Maximum length is {NAME_MAX_LENGTH} characters." + f"Name is too long ({byte_len} bytes). " + f"Maximum length is {NAME_MAX_LENGTH} bytes." ) # Validate no '/' in name for web server URL compatibility value = _validate_no_slash(value) diff --git a/esphome/core/config.py b/esphome/core/config.py index efc6d62f48..bf210876df 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -246,7 +246,7 @@ AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), cv.Required(CONF_NAME): cv.All( - cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), } ) @@ -255,7 +255,7 @@ DEVICE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Device), cv.Required(CONF_NAME): cv.All( - cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA_ID): cv.use_id(Area), } @@ -272,7 +272,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_NAME): cv.valid_name, # Keep max=120 in sync with OBJECT_ID_MAX_LEN in esphome/core/entity_base.h cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All( - cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA): validate_area_config, cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)), @@ -314,10 +314,10 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_NAME): cv.All( cv.string_strict, valid_project_name, - cv.Length(max=PROJECT_MAX_LENGTH), + cv.ByteLength(max=PROJECT_MAX_LENGTH), ), cv.Required(CONF_VERSION): cv.All( - cv.string_strict, cv.Length(max=PROJECT_MAX_LENGTH) + cv.string_strict, cv.ByteLength(max=PROJECT_MAX_LENGTH) ), cv.Optional(CONF_ON_UPDATE): automation.validate_automation( { diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index fc931c2baa..f09dd013fe 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -193,9 +193,10 @@ def _register_string( def register_device_class(value: str) -> int: """Register a device_class string and return its 1-based index.""" - if value and len(value) > DEVICE_CLASS_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) if value else 0 + if byte_len > DEVICE_CLASS_MAX_LENGTH: raise ValueError( - f"Device class string too long ({len(value)} chars, max {DEVICE_CLASS_MAX_LENGTH}): '{value}'" + f"Device class string too long ({byte_len} bytes, max {DEVICE_CLASS_MAX_LENGTH}): '{value}'" ) return _register_string( value, _get_pool().device_classes, _MAX_DEVICE_CLASSES, "device_class" @@ -204,9 +205,10 @@ def register_device_class(value: str) -> int: def register_unit_of_measurement(value: str) -> int: """Register a unit_of_measurement string and return its 1-based index.""" - if value and len(value) > UNIT_OF_MEASUREMENT_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) if value else 0 + if byte_len > UNIT_OF_MEASUREMENT_MAX_LENGTH: raise ValueError( - f"Unit of measurement string too long ({len(value)} chars, " + f"Unit of measurement string too long ({byte_len} bytes, " f"max {UNIT_OF_MEASUREMENT_MAX_LENGTH}): '{value}'" ) return _register_string(value, _get_pool().units, _MAX_UNITS, "unit_of_measurement") @@ -214,9 +216,10 @@ def register_unit_of_measurement(value: str) -> int: def register_icon(value: str) -> int: """Register an icon string and return its 1-based index.""" - if value and len(value) > ICON_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) if value else 0 + if byte_len > ICON_MAX_LENGTH: raise ValueError( - f"Icon string too long ({len(value)} chars, max {ICON_MAX_LENGTH}): '{value}'" + f"Icon string too long ({byte_len} bytes, max {ICON_MAX_LENGTH}): '{value}'" ) return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon") From b86ab0763297cbde2ff0d986570bd923f5e195af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Apr 2026 18:38:42 -1000 Subject: [PATCH 5/5] [core] Fix test for byte-length error message, add ByteLength and multibyte tests --- tests/unit_tests/test_config_validation.py | 44 ++++++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index c1849daf4b..ce941b40dc 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -149,17 +149,36 @@ def test_icon__invalid(): def test_icon__max_length(): - """Test that icons exceeding 63 characters are rejected.""" - # Exactly 63 chars should pass - max_icon = "mdi:" + "a" * 59 # 63 chars total + """Test that icons exceeding 63 bytes are rejected.""" + # Exactly 63 bytes should pass + max_icon = "mdi:" + "a" * 59 # 63 bytes total assert config_validation.icon(max_icon) == max_icon - # 64 chars should fail - too_long = "mdi:" + "a" * 60 # 64 chars total + # 64 bytes should fail + too_long = "mdi:" + "a" * 60 # 64 bytes total with pytest.raises(Invalid, match="Icon string is too long"): config_validation.icon(too_long) +def test_byte_length() -> None: + """Test ByteLength validator checks UTF-8 byte length, not char count.""" + validator = config_validation.ByteLength(max=10) # pylint: disable=no-member + + # ASCII: 10 chars = 10 bytes, should pass + assert validator("a" * 10) == "a" * 10 + + # ASCII: 11 chars = 11 bytes, should fail + with pytest.raises(Invalid, match="too long.*11 bytes.*max 10"): + validator("a" * 11) + + # Multibyte: 3 chars × 3 bytes = 9 bytes, should pass + assert validator("温度传") == "温度传" + + # Multibyte: 4 chars × 3 bytes = 12 bytes, should fail + with pytest.raises(Invalid, match="too long.*12 bytes.*max 10"): + validator("温度传感") + + @pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) def test_boolean__valid_true(value): assert config_validation.boolean(value) is True @@ -567,14 +586,23 @@ def test_validate_entity_name__slash_replaced_with_warning( def test_validate_entity_name__max_length() -> None: - # 120 chars should pass + # 120 bytes should pass assert config_validation._validate_entity_name("x" * 120) == "x" * 120 - # 121 chars should fail - with pytest.raises(Invalid, match="too long.*121 chars.*Maximum.*120"): + # 121 bytes should fail + with pytest.raises(Invalid, match="too long.*121 bytes.*Maximum.*120"): config_validation._validate_entity_name("x" * 121) +def test_validate_entity_name__multibyte_byte_length() -> None: + # 40 chars of 3-byte UTF-8 = 120 bytes, should pass + assert config_validation._validate_entity_name("温" * 40) == "温" * 40 + + # 41 chars of 3-byte UTF-8 = 123 bytes, should fail (over 120 byte limit) + with pytest.raises(Invalid, match="too long.*123 bytes.*Maximum.*120"): + config_validation._validate_entity_name("温" * 41) + + def test_validate_entity_name__none_without_friendly_name() -> None: # When name is "None" and friendly_name is not set, it should fail CORE.friendly_name = None