From d0285cdc41d479ed0aa7dc0984a71215ee4ff696 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 15:11:15 -1000 Subject: [PATCH 1/5] [core] Pack entity flags into configure_entity_() and protect setters (#14564) Co-authored-by: Claude Opus 4.6 --- esphome/core/entity_base.cpp | 14 +- esphome/core/entity_base.h | 31 ++- esphome/core/entity_helpers.py | 84 ++++++- .../binary_sensor/test_binary_sensor.py | 8 +- tests/component_tests/button/test_button.py | 8 +- tests/component_tests/helpers.py | 19 ++ tests/component_tests/sensor/test_sensor.py | 12 +- tests/component_tests/text/test_text.py | 10 +- .../text_sensor/test_text_sensor.py | 20 +- tests/unit_tests/core/test_entity_helpers.py | 222 +++++++++++++++++- 10 files changed, 354 insertions(+), 74 deletions(-) create mode 100644 tests/component_tests/helpers.py diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 3274640eb34..818dae06de1 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -11,7 +11,7 @@ static const char *const TAG = "entity_base"; // Entity Name const StringRef &EntityBase::get_name() const { return this->name_; } -void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_strings_packed) { +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -44,17 +44,19 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui this->calc_object_id_(); } } - // Unpack entity string table indices. - // Packed: [23..16] icon | [15..8] UoM | [7..0] device_class (each 8 bits) + // Unpack entity string table indices and flags from entity_fields. #ifdef USE_ENTITY_DEVICE_CLASS - this->device_class_idx_ = entity_strings_packed & 0xFF; + this->device_class_idx_ = (entity_fields >> ENTITY_FIELD_DC_SHIFT) & 0xFF; #endif #ifdef USE_ENTITY_UNIT_OF_MEASUREMENT - this->uom_idx_ = (entity_strings_packed >> 8) & 0xFF; + this->uom_idx_ = (entity_fields >> ENTITY_FIELD_UOM_SHIFT) & 0xFF; #endif #ifdef USE_ENTITY_ICON - this->icon_idx_ = (entity_strings_packed >> 16) & 0xFF; + this->icon_idx_ = (entity_fields >> ENTITY_FIELD_ICON_SHIFT) & 0xFF; #endif + this->flags_.internal = (entity_fields >> ENTITY_FIELD_INTERNAL_SHIFT) & 1; + this->flags_.disabled_by_default = (entity_fields >> ENTITY_FIELD_DISABLED_BY_DEFAULT_SHIFT) & 1; + this->flags_.entity_category = (entity_fields >> ENTITY_FIELD_ENTITY_CATEGORY_SHIFT) & 0x3; } // Weak default lookup functions — overridden by generated code in main.cpp diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index accd532b0d0..cccbafd2c36 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -55,6 +55,15 @@ enum EntityCategory : uint8_t { ENTITY_CATEGORY_DIAGNOSTIC = 2, }; +// Bit layout for entity_fields parameter in configure_entity_(). +// Keep in sync with _*_SHIFT constants in esphome/core/entity_helpers.py +static constexpr uint8_t ENTITY_FIELD_DC_SHIFT = 0; +static constexpr uint8_t ENTITY_FIELD_UOM_SHIFT = 8; +static constexpr uint8_t ENTITY_FIELD_ICON_SHIFT = 16; +static constexpr uint8_t ENTITY_FIELD_INTERNAL_SHIFT = 24; +static constexpr uint8_t ENTITY_FIELD_DISABLED_BY_DEFAULT_SHIFT = 25; +static constexpr uint8_t ENTITY_FIELD_ENTITY_CATEGORY_SHIFT = 26; + // The generic Entity base class that provides an interface common to all Entities. class EntityBase { public: @@ -88,21 +97,16 @@ class EntityBase { /// Useful for building compound strings without intermediate buffer size_t write_object_id_to(char *buf, size_t buf_size) const; - // Get/set whether this Entity should be hidden outside ESPHome + // Get whether this Entity should be hidden outside ESPHome bool is_internal() const { return this->flags_.internal; } - void set_internal(bool internal) { this->flags_.internal = internal; } // Check if this object is declared to be disabled by default. // That means that when the device gets added to Home Assistant (or other clients) it should // not be added to the default view by default, and a user action is necessary to manually add it. bool is_disabled_by_default() const { return this->flags_.disabled_by_default; } - void set_disabled_by_default(bool disabled_by_default) { this->flags_.disabled_by_default = disabled_by_default; } - // Get/set the entity category. + // Get the entity category. EntityCategory get_entity_category() const { return static_cast(this->flags_.entity_category); } - void set_entity_category(EntityCategory entity_category) { - this->flags_.entity_category = static_cast(entity_category); - } // Get this entity's device class into a stack buffer. // On non-ESP8266: returns pointer to PROGMEM string directly (buffer unused). @@ -164,14 +168,13 @@ class EntityBase { #endif #ifdef USE_DEVICES - // Get/set this entity's device id + // Get this entity's device id uint32_t get_device_id() const { if (this->device_ == nullptr) { return 0; // No device set, return 0 } return this->device_->get_device_id(); } - void set_device(Device *device) { this->device_ = device; } // Get the device this entity belongs to (nullptr if main device) Device *get_device() const { return this->device_; } #endif @@ -228,8 +231,14 @@ class EntityBase { friend void ::setup(); friend void ::original_setup(); - /// Combined entity setup from codegen: set name, object_id hash, and entity string indices. - void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_strings_packed); + /// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags. + /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); + +#ifdef USE_DEVICES + // Codegen-only setter — only accessible from setup() via friend declaration. + void set_device_(Device *device) { this->device_ = device; } +#endif /// Non-template helper for make_entity_preference() to avoid code bloat. /// When preference hash algorithm changes, migration logic goes here. diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 4fa109fb0e1..0589b92364a 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -34,11 +34,19 @@ _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" _KEY_OBJECT_ID_HASH = "_entity_object_id_hash" -# Bit layout for entity_strings_packed in configure_entity_() — must match C++ in entity_base.h: -# [23..16] icon (8 bits) | [15..8] UoM (8 bits) | [7..0] device_class (8 bits) +# Bit layout for entity_fields in configure_entity_(). +# Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h _DC_SHIFT = 0 _UOM_SHIFT = 8 _ICON_SHIFT = 16 +_INTERNAL_SHIFT = 24 +_DISABLED_BY_DEFAULT_SHIFT = 25 +_ENTITY_CATEGORY_SHIFT = 26 + +# Private config keys for storing flags +_KEY_INTERNAL = "_entity_internal" +_KEY_DISABLED_BY_DEFAULT = "_entity_disabled_by_default" +_KEY_ENTITY_CATEGORY = "_entity_category" # Maximum unique strings per category (8-bit index, 0 = not set) _MAX_DEVICE_CLASSES = 0xFF # 255 @@ -220,8 +228,39 @@ def setup_unit_of_measurement(config: ConfigType) -> None: config[_KEY_UOM_IDX] = idx +def _sanitize_comment(text: str) -> str: + r"""Sanitize a string for safe inclusion in a C++ // line comment. + + Dangerous characters: + - \n, \r: break out of line comment, next line becomes code + - \: at end of line, splices next line into comment (eats real code) + """ + return text.replace("\\", "/").replace("\n", " ").replace("\r", "") + + +def _describe_packed_flags(config: ConfigType, entity_category: int) -> str: + """Build a human-readable description of packed entity flags for C++ comments.""" + parts: list[str] = [] + if config.get(_KEY_INTERNAL): + parts.append("internal") + if config.get(_KEY_DISABLED_BY_DEFAULT): + parts.append("disabled_by_default") + entity_cat_keys = list(cv.ENTITY_CATEGORIES) + if entity_category < len(entity_cat_keys) and ( + cat_name := entity_cat_keys[entity_category] + ): + parts.append(f"category:{cat_name}") + if config.get(_KEY_DC_IDX) and (dc := config.get(CONF_DEVICE_CLASS)): + parts.append(f"dc:{_sanitize_comment(dc)}") + if config.get(_KEY_UOM_IDX) and (uom := config.get(CONF_UNIT_OF_MEASUREMENT)): + parts.append(f"uom:{_sanitize_comment(uom)}") + if config.get(_KEY_ICON_IDX) and (icon := config.get(CONF_ICON)): + parts.append(f"icon:{_sanitize_comment(icon)}") + return ", ".join(parts) + + def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: - """Emit a single configure_entity_() call with name, hash, and packed string indices. + """Emit a single configure_entity_() call with name, hash, packed string indices, and flags. Call this at the end of each component's setup function, after setup_entity() and any register_device_class/register_unit_of_measurement calls. @@ -231,8 +270,24 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) - packed = (dc_idx << _DC_SHIFT) | (uom_idx << _UOM_SHIFT) | (icon_idx << _ICON_SHIFT) - add(var.configure_entity_(entity_name, object_id_hash, packed)) + internal = config.get(_KEY_INTERNAL, 0) + disabled_by_default = config.get(_KEY_DISABLED_BY_DEFAULT, 0) + entity_category = config.get(_KEY_ENTITY_CATEGORY, 0) + packed = ( + (dc_idx << _DC_SHIFT) + | (uom_idx << _UOM_SHIFT) + | (icon_idx << _ICON_SHIFT) + | (internal << _INTERNAL_SHIFT) + | (disabled_by_default << _DISABLED_BY_DEFAULT_SHIFT) + | (entity_category << _ENTITY_CATEGORY_SHIFT) + ) + # Build inline comment describing the packed flags for readability + comment = _describe_packed_flags(config, entity_category) + expr = var.configure_entity_(entity_name, object_id_hash, packed) + if comment: + add(RawStatement(f"{expr}; // {comment}")) + else: + add(expr) def get_base_entity_object_id( @@ -332,7 +387,7 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> # Get device info if configured if device_id_obj := config.get(CONF_DEVICE_ID): device: MockObj = await get_variable(device_id_obj) - add(var.set_device(device)) + add(var.set_device_(device)) # Pre-compute entity name and object_id hash for configure_entity_() # which is emitted later by finalize_entity_strings(). @@ -343,18 +398,25 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name config[_KEY_OBJECT_ID_HASH] = object_id_hash - # Only set disabled_by_default if True (default is False) - if config[CONF_DISABLED_BY_DEFAULT]: - add(var.set_disabled_by_default(True)) + # Store flags for packing into configure_entity_() + config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: - add(var.set_internal(config[CONF_INTERNAL])) + config[_KEY_INTERNAL] = int(config[CONF_INTERNAL]) icon_idx = 0 if CONF_ICON in config: # Add USE_ENTITY_ICON define when icons are used cg.add_define("USE_ENTITY_ICON") icon_idx = register_icon(config[CONF_ICON]) if CONF_ENTITY_CATEGORY in config: - add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) + # Derive integer value from key position in cv.ENTITY_CATEGORIES + # (must match C++ EntityCategory enum in entity_base.h) + entity_cat_str = str(config[CONF_ENTITY_CATEGORY]) + entity_cat_keys = list(cv.ENTITY_CATEGORIES) + config[_KEY_ENTITY_CATEGORY] = ( + entity_cat_keys.index(entity_cat_str) + if entity_cat_str in entity_cat_keys + else 0 + ) # Store icon index for finalize_entity_strings config[_KEY_ICON_IDX] = icon_idx diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index fbc2f37d9a1..10d7f808346 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -1,5 +1,7 @@ """Tests for the binary sensor component.""" +from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value + def test_binary_sensor_is_setup(generate_main): """ @@ -44,9 +46,9 @@ def test_binary_sensor_config_value_internal_set(generate_main): "tests/component_tests/binary_sensor/test_binary_sensor.yaml" ) - # Then - assert "bs_1->set_internal(true);" in main_cpp - assert "bs_2->set_internal(false);" in main_cpp + # Then: bs_1 has internal: true, bs_2 has internal: false + assert extract_packed_value(main_cpp, "bs_1") & INTERNAL_BIT != 0 + assert extract_packed_value(main_cpp, "bs_2") & INTERNAL_BIT == 0 def test_binary_sensor_config_value_use_raw_set(generate_main): diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index 9f94d61c8c4..a35994a682c 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -1,5 +1,7 @@ """Tests for the button component""" +from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value + def test_button_is_setup(generate_main): """ @@ -39,6 +41,6 @@ def test_button_config_value_internal_set(generate_main): # When main_cpp = generate_main("tests/component_tests/button/test_button.yaml") - # Then - assert "wol_1->set_internal(true);" in main_cpp - assert "wol_2->set_internal(false);" in main_cpp + # Then: wol_1 has internal: true, wol_2 has internal: false + assert extract_packed_value(main_cpp, "wol_1") & INTERNAL_BIT != 0 + assert extract_packed_value(main_cpp, "wol_2") & INTERNAL_BIT == 0 diff --git a/tests/component_tests/helpers.py b/tests/component_tests/helpers.py new file mode 100644 index 00000000000..568d1639d0c --- /dev/null +++ b/tests/component_tests/helpers.py @@ -0,0 +1,19 @@ +"""Shared helpers for component tests.""" + +from __future__ import annotations + +import re + +INTERNAL_BIT = 1 << 24 + + +def extract_packed_value(main_cpp: str, var_name: str) -> int: + """Extract the third (packed) argument from a configure_entity_ call.""" + pattern = ( + rf"{re.escape(var_name)}->configure_entity_\(" + r'"(?:\\.|[^"\\])*"' + r",\s*\w+,\s*(\d+)\)" + ) + match = re.search(pattern, main_cpp) + assert match, f"configure_entity_ call not found for {var_name}" + return int(match.group(1)) diff --git a/tests/component_tests/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index d9ab3a022c8..9d18fa36b8f 100644 --- a/tests/component_tests/sensor/test_sensor.py +++ b/tests/component_tests/sensor/test_sensor.py @@ -1,14 +1,6 @@ """Tests for the sensor component.""" -import re - - -def _extract_packed_value(main_cpp, var_name): - """Extract the third (packed) argument from a configure_entity_ call.""" - pattern = rf"{re.escape(var_name)}->configure_entity_\([^,]+,\s*\w+,\s*(\d+)\)" - match = re.search(pattern, main_cpp) - assert match, f"configure_entity_ call not found for {var_name}" - return int(match.group(1)) +from tests.component_tests.helpers import extract_packed_value def test_sensor_device_class_set(generate_main): @@ -21,5 +13,5 @@ def test_sensor_device_class_set(generate_main): main_cpp = generate_main("tests/component_tests/sensor/test_sensor.yaml") # Then: device_class: voltage means packed value must be non-zero - packed = _extract_packed_value(main_cpp, "s_1") + packed = extract_packed_value(main_cpp, "s_1") assert packed != 0 diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 3ceaa9b8f81..c74dfb8a471 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -1,4 +1,6 @@ -"""Tests for the binary sensor component.""" +"""Tests for the text component.""" + +from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value def test_text_is_setup(generate_main): @@ -37,9 +39,9 @@ def test_text_config_value_internal_set(generate_main): # When main_cpp = generate_main("tests/component_tests/text/test_text.yaml") - # Then - assert "it_2->set_internal(false);" in main_cpp - assert "it_3->set_internal(true);" in main_cpp + # Then: it_2 has internal: false, it_3 has internal: true + assert extract_packed_value(main_cpp, "it_2") & INTERNAL_BIT == 0 + assert extract_packed_value(main_cpp, "it_3") & INTERNAL_BIT != 0 def test_text_config_value_mode_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index f30b820e94d..1ff31ab96bd 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -1,14 +1,6 @@ """Tests for the text sensor component.""" -import re - - -def _extract_packed_value(main_cpp, var_name): - """Extract the third (packed) argument from a configure_entity_ call.""" - pattern = rf"{re.escape(var_name)}->configure_entity_\([^,]+,\s*\w+,\s*(\d+)\)" - match = re.search(pattern, main_cpp) - assert match, f"configure_entity_ call not found for {var_name}" - return int(match.group(1)) +from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value def test_text_sensor_is_setup(generate_main): @@ -49,9 +41,9 @@ def test_text_sensor_config_value_internal_set(generate_main): # When main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") - # Then - assert "ts_2->set_internal(true);" in main_cpp - assert "ts_3->set_internal(false);" in main_cpp + # Then: ts_2 has internal: true, ts_3 has internal: false + assert extract_packed_value(main_cpp, "ts_2") & INTERNAL_BIT != 0 + assert extract_packed_value(main_cpp, "ts_3") & INTERNAL_BIT == 0 def test_text_sensor_device_class_set(generate_main): @@ -65,7 +57,7 @@ def test_text_sensor_device_class_set(generate_main): # Then: ts_2 has device_class: timestamp, ts_3 has device_class: date # so their packed values must be non-zero - packed_ts_2 = _extract_packed_value(main_cpp, "ts_2") + packed_ts_2 = extract_packed_value(main_cpp, "ts_2") assert packed_ts_2 != 0 - packed_ts_3 = _extract_packed_value(main_cpp, "ts_3") + packed_ts_3 = extract_packed_value(main_cpp, "ts_3") assert packed_ts_3 != 0 diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 3f6faaee54c..d6cbb8c6be0 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -9,6 +9,7 @@ import pytest from esphome.config_validation import Invalid from esphome.const import ( + CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ENTITY_CATEGORY, @@ -16,16 +17,20 @@ from esphome.const import ( CONF_ID, CONF_INTERNAL, CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, ) from esphome.core import CORE, ID, entity_helpers from esphome.core.entity_helpers import ( _register_string, _setup_entity_impl, entity_duplicate_validator, + finalize_entity_strings, get_base_entity_object_id, register_device_class, register_icon, + setup_device_class, setup_entity, + setup_unit_of_measurement, ) from esphome.cpp_generator import MockObj from esphome.helpers import sanitize, snake_case @@ -486,8 +491,6 @@ async def test_setup_entity_disabled_by_default( ) -> None: """Test setup_entity sets disabled_by_default correctly.""" - added_expressions = setup_test_environment - var = MockObj("sensor1") config = { @@ -497,10 +500,8 @@ async def test_setup_entity_disabled_by_default( await _setup_entity_impl(var, config, "sensor") - # Check disabled_by_default was set - assert any( - "sensor1.set_disabled_by_default(true)" in expr for expr in added_expressions - ) + # disabled_by_default is now packed into config for configure_entity_() + assert config.get("_entity_disabled_by_default") == 1 def test_entity_duplicate_validator() -> None: @@ -785,8 +786,8 @@ async def test_setup_entity_empty_name_with_device( entity_helpers.get_variable = original_get_variable - # Check that set_device was called - assert any("sensor1.set_device" in expr for expr in added_expressions) + # Check that set_device_ was called (separate protected call, accessible via friend) + assert any("sensor1.set_device_" in expr for expr in added_expressions) # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" @@ -928,7 +929,7 @@ def test_register_device_class_max_length() -> None: async def test_setup_entity_with_entity_category( setup_test_environment: list[str], ) -> None: - """Test setup_entity sets entity_category correctly.""" + """Test entity_category is packed correctly through the full setup flow.""" added_expressions = setup_test_environment var = MockObj("sensor1") config = { @@ -937,9 +938,10 @@ async def test_setup_entity_with_entity_category( CONF_ENTITY_CATEGORY: "diagnostic", } await _setup_entity_impl(var, config, "sensor") - assert any( - 'set_entity_category("diagnostic")' in expr for expr in added_expressions - ) + finalize_entity_strings(var, config) + packed = _extract_packed_value(added_expressions) + assert packed != 0 + assert "category:diagnostic" in added_expressions[0] @pytest.mark.asyncio @@ -988,3 +990,199 @@ async def test_setup_entity_decorator_mode(setup_test_environment: list[str]) -> assert body_called object_id = extract_object_id_from_expressions(added_expressions) assert object_id == "temperature" + + +# Tests for finalize_entity_strings packing +# +# These tests verify that flags and string indices produce non-zero packed values +# and correct inline comments. The actual bit layout correctness (Python _*_SHIFT +# matching C++ ENTITY_FIELD_*_SHIFT) is verified end-to-end by the integration +# test test_host_mode_entity_fields, which compiles firmware and checks values +# via the native API. + + +def _extract_packed_value(expressions: list[str]) -> int: + """Extract the third argument (packed value) from a configure_entity_() call.""" + for expr in expressions: + if "configure_entity_" in expr: + # Match the last integer argument before the closing ");" + match = re.search(r",\s*(\d+)\s*\)", expr) + if match: + return int(match.group(1)) + raise AssertionError("No configure_entity_ call found") + + +@pytest.mark.asyncio +async def test_finalize_no_flags(setup_test_environment: list[str]) -> None: + """Test entity with no special flags — packed value is 0, no comment.""" + added_expressions = setup_test_environment + var = MockObj("sensor1") + config = { + CONF_NAME: "Test", + CONF_DISABLED_BY_DEFAULT: False, + } + await _setup_entity_impl(var, config, "sensor") + finalize_entity_strings(var, config) + packed = _extract_packed_value(added_expressions) + assert packed == 0 + assert "//" not in added_expressions[0] + + +@pytest.mark.asyncio +async def test_finalize_internal(setup_test_environment: list[str]) -> None: + """Test entity with internal=True packs the internal flag.""" + added_expressions = setup_test_environment + var = MockObj("sensor1") + config = { + CONF_NAME: "Test", + CONF_DISABLED_BY_DEFAULT: False, + CONF_INTERNAL: True, + } + await _setup_entity_impl(var, config, "sensor") + finalize_entity_strings(var, config) + packed = _extract_packed_value(added_expressions) + assert packed != 0 + assert "// internal" in added_expressions[0] + + +@pytest.mark.asyncio +async def test_finalize_disabled_by_default( + setup_test_environment: list[str], +) -> None: + """Test entity with disabled_by_default=True packs the flag.""" + added_expressions = setup_test_environment + var = MockObj("sensor1") + config = { + CONF_NAME: "Test", + CONF_DISABLED_BY_DEFAULT: True, + } + await _setup_entity_impl(var, config, "sensor") + finalize_entity_strings(var, config) + packed = _extract_packed_value(added_expressions) + assert packed != 0 + assert "// disabled_by_default" in added_expressions[0] + + +@pytest.mark.asyncio +async def test_finalize_entity_category( + setup_test_environment: list[str], +) -> None: + """Test entity_category values are packed and described in comment.""" + added_expressions = setup_test_environment + var = MockObj("sensor1") + + # Test diagnostic + config = { + CONF_NAME: "Test", + CONF_DISABLED_BY_DEFAULT: False, + CONF_ENTITY_CATEGORY: "diagnostic", + } + await _setup_entity_impl(var, config, "sensor") + finalize_entity_strings(var, config) + packed_diag = _extract_packed_value(added_expressions) + assert packed_diag != 0 + assert "category:diagnostic" in added_expressions[0] + + # Test config — different packed value + added_expressions.clear() + config2 = { + CONF_NAME: "Test2", + CONF_DISABLED_BY_DEFAULT: False, + CONF_ENTITY_CATEGORY: "config", + } + await _setup_entity_impl(var, config2, "sensor") + finalize_entity_strings(var, config2) + packed_cfg = _extract_packed_value(added_expressions) + assert packed_cfg != 0 + assert packed_cfg != packed_diag + assert "category:config" in added_expressions[0] + + +@pytest.mark.asyncio +async def test_finalize_string_indices( + setup_test_environment: list[str], +) -> None: + """Test device_class, unit_of_measurement, and icon produce non-zero packed value.""" + added_expressions = setup_test_environment + var = MockObj("sensor1") + config = { + CONF_NAME: "Test", + CONF_DISABLED_BY_DEFAULT: False, + CONF_DEVICE_CLASS: "temperature", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_ICON: "mdi:thermometer", + } + await _setup_entity_impl(var, config, "sensor") + setup_device_class(config) + setup_unit_of_measurement(config) + finalize_entity_strings(var, config) + packed = _extract_packed_value(added_expressions) + assert packed != 0 + comment = added_expressions[0] + assert "dc:temperature" in comment + assert "uom:°C" in comment + assert "icon:mdi:thermometer" in comment + + +@pytest.mark.asyncio +async def test_finalize_all_fields( + setup_test_environment: list[str], +) -> None: + """Test all fields set: flags, string indices, and comment.""" + added_expressions = setup_test_environment + var = MockObj("sensor1") + config = { + CONF_NAME: "Test", + CONF_DISABLED_BY_DEFAULT: True, + CONF_INTERNAL: True, + CONF_ENTITY_CATEGORY: "diagnostic", + CONF_DEVICE_CLASS: "temperature", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_ICON: "mdi:thermometer", + } + await _setup_entity_impl(var, config, "sensor") + setup_device_class(config) + setup_unit_of_measurement(config) + finalize_entity_strings(var, config) + packed = _extract_packed_value(added_expressions) + assert packed != 0 + # Verify comment contains all flags with actual string values + comment_line = added_expressions[0] + assert ( + "// internal, disabled_by_default, category:diagnostic," + " dc:temperature, uom:°C, icon:mdi:thermometer" in comment_line + ) + + +@pytest.mark.asyncio +async def test_finalize_comment_sanitization( + setup_test_environment: list[str], +) -> None: + """Test that user strings in comments are sanitized against injection.""" + added_expressions = setup_test_environment + var = MockObj("sensor1") + config = { + CONF_NAME: "Test", + CONF_DISABLED_BY_DEFAULT: False, + # Backslash at end would cause line splice eating next code line + CONF_ICON: "mdi:evil\\", + } + await _setup_entity_impl(var, config, "sensor") + finalize_entity_strings(var, config) + comment_line = added_expressions[0] + # Backslash must be replaced to prevent line splice + assert "\\" not in comment_line + assert "mdi:evil/" in comment_line + + added_expressions.clear() + config2 = { + CONF_NAME: "Test2", + CONF_DISABLED_BY_DEFAULT: False, + CONF_ICON: "mdi:evil\nINJECTED_CODE();", + } + await _setup_entity_impl(var, config2, "sensor") + finalize_entity_strings(var, config2) + comment_line = added_expressions[0] + # Newline must be replaced to prevent breaking out of comment + assert "\n" not in comment_line + assert "INJECTED_CODE" in comment_line # still visible but safe in comment From c681dc8872f88f894bb3b71026ffbec013c1bdc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 15:11:24 -1000 Subject: [PATCH 2/5] [socket] Add socket wake support for RP2040 (#14498) --- .../components/socket/lwip_raw_tcp_impl.cpp | 73 ++++++++++++++++++- esphome/components/socket/socket.h | 12 ++- esphome/core/application.cpp | 8 +- esphome/core/application.h | 8 +- esphome/core/component.cpp | 4 +- 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index d697bd47a50..445a57809d2 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -11,6 +11,9 @@ #ifdef USE_ESP8266 #include // For esp_schedule() +#elif defined(USE_RP2040) +#include // For __sev(), __wfe() +#include // For add_alarm_in_ms(), cancel_alarm() #endif namespace esphome::socket { @@ -40,6 +43,72 @@ void IRAM_ATTR socket_wake() { s_socket_woke = true; esp_schedule(); } +#elif defined(USE_RP2040) +// RP2040 (non-FreeRTOS) socket wake using hardware WFE/SEV instructions. +// +// Same pattern as ESP8266's esp_delay()/esp_schedule(): set a one-shot timer, +// then sleep with __wfe(). Wake on either: +// - Timer alarm fires → callback calls __sev() → __wfe() returns → timeout +// - Socket data arrives → LWIP callback calls socket_wake() → __sev() → __wfe() returns → early wake +// +// CYW43 WiFi chip communicates via SPI interrupts on core 0. When data arrives, +// the GPIO interrupt fires → async_context pendsv processes CYW43/LWIP → recv/accept +// callbacks call socket_wake() → __sev() wakes the main loop from __wfe() sleep. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_socket_woke = false; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_delay_expired = false; + +static int64_t alarm_callback(alarm_id_t id, void *user_data) { + (void) id; + (void) user_data; + s_delay_expired = true; + // Wake the main loop from __wfe() sleep — timeout expired. + __sev(); + // Return 0 = don't reschedule (one-shot) + return 0; +} + +void socket_delay(uint32_t ms) { + if (ms == 0) { + yield(); + return; + } + // If a wake was already signalled, consume it and return immediately + // instead of going to sleep. This avoids losing a wake that arrived + // between loop iterations. + if (s_socket_woke) { + s_socket_woke = false; + return; + } + s_socket_woke = false; + s_delay_expired = false; + // Set a one-shot timer to wake us after the timeout. + // add_alarm_in_ms returns >0 on success, 0 if time already passed, <0 on error. + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback, nullptr, true); + if (alarm <= 0) { + delay(ms); + return; + } + // Sleep until woken by either the timer alarm or socket_wake(). + // __wfe() may return spuriously (stale event register, other interrupts), + // so we loop checking both flags. + while (!s_socket_woke && !s_delay_expired) { + __wfe(); + } + // Cancel timer if we woke early (socket data arrived before timeout) + if (!s_delay_expired) + cancel_alarm(alarm); +} + +// No IRAM_ATTR equivalent needed: on RP2040, CYW43 async_context runs LWIP +// callbacks via pendsv (not hard IRQ), so they execute from flash safely. +void socket_wake() { + s_socket_woke = true; + // Wake the main loop from __wfe() sleep. __sev() is a global event that + // wakes any core sleeping in __wfe(). This is ISR-safe. + __sev(); +} #endif static const char *const TAG = "socket.lwip"; @@ -371,7 +440,7 @@ err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { } else { pbuf_cat(this->rx_buf_, pb); } -#ifdef USE_ESP8266 +#if (defined(USE_ESP8266) || defined(USE_RP2040)) // Wake the main loop immediately so it can process the received data. socket_wake(); #endif @@ -650,7 +719,7 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { sock->init(); this->accepted_sockets_[this->accepted_socket_count_++] = std::move(sock); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); -#ifdef USE_ESP8266 +#if (defined(USE_ESP8266) || defined(USE_RP2040)) // Wake the main loop immediately so it can accept the new connection. socket_wake(); #endif diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 0884e4ba3e6..a21bd647305 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -120,13 +120,17 @@ socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t po /// Format sockaddr into caller-provided buffer, returns length written (excluding null) size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span buf); -#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) /// Delay that can be woken early by socket activity. -/// On ESP8266, lwip callbacks set a flag and call esp_schedule() to wake the delay. +/// On ESP8266, uses esp_delay() with a callback that checks socket activity. +/// On RP2040, uses __wfe() (Wait For Event) to truly sleep until an interrupt +/// (for example, CYW43 GPIO or a timer alarm) fires and wakes the CPU. void socket_delay(uint32_t ms); -/// Signal socket/IO activity and wake the main loop from esp_delay() early. -/// ISR-safe: uses IRAM_ATTR internally and only sets a volatile flag + esp_schedule(). +/// Signal socket/IO activity and wake the main loop early. +/// On ESP8266: sets flag + esp_schedule(). +/// On RP2040: sets flag + __sev() (Send Event) to wake from __wfe(). +/// ISR-safe on both platforms. void socket_wake(); // NOLINT(readability-redundant-declaration) #endif diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index db1c8a0c0a1..8685bff360e 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -32,7 +32,7 @@ #include "esphome/components/status_led/status_led.h" #endif -#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) #include "esphome/components/socket/socket.h" #endif @@ -713,8 +713,10 @@ void Application::yield_with_select_(uint32_t delay_ms) { } // No sockets registered or select() failed - use regular delay delay(delay_ms); -#elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) - // No select support but can wake on socket activity via esp_schedule() +#elif (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) + // No select support but can wake on socket activity + // ESP8266: via esp_schedule() + // RP2040: via __sev()/__wfe() hardware sleep/wake socket::socket_delay(delay_ms); #else // No select support, use regular delay diff --git a/esphome/core/application.h b/esphome/core/application.h index 49253b63244..f357c6b1a3d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -34,7 +34,7 @@ #endif #endif #endif // USE_SOCKET_SELECT_SUPPORT -#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) namespace esphome::socket { void socket_wake(); // NOLINT(readability-redundant-declaration) } // namespace esphome::socket @@ -565,8 +565,12 @@ class Application { #if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) /// Wake the main event loop from any context (ISR, thread, or main loop). - /// On ESP8266: sets the socket wake flag and calls esp_schedule() to exit esp_delay() early. + /// Sets the socket wake flag and calls esp_schedule() to exit esp_delay() early. static void IRAM_ATTR wake_loop_any_context() { socket::socket_wake(); } +#elif defined(USE_RP2040) && defined(USE_SOCKET_IMPL_LWIP_TCP) + /// Wake the main event loop from any context. + /// Sets the socket wake flag and calls __sev() to exit __wfe() early. + static void wake_loop_any_context() { socket::socket_wake(); } #endif protected: diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2879d4b5ab1..cce0c7b3e04 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -322,11 +322,13 @@ void IRAM_ATTR HOT Component::enable_loop_soon_any_context() { // 8. Race condition with main loop is handled by clearing flag before processing this->pending_enable_loop_ = true; App.has_pending_enable_loop_requests_ = true; -#if (defined(USE_LWIP_FAST_SELECT) && defined(USE_ESP32)) || (defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)) +#if (defined(USE_LWIP_FAST_SELECT) && defined(USE_ESP32)) || \ + ((defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP)) // Wake the main loop from sleep. Without this, the main loop would not // wake until the select/delay timeout expires (~16ms). // ESP32: uses xPortInIsrContext() to choose the correct FreeRTOS notify API. // ESP8266: sets socket wake flag and calls esp_schedule() to exit esp_delay() early. + // RP2040: sets socket wake flag and calls __sev() to exit __wfe() early. Application::wake_loop_any_context(); #endif } From 6ba5c9a7056a07fe431ebf3689dbda307892ea85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 15:22:39 -1000 Subject: [PATCH 3/5] [api] Skip state_action_() call in noise data path (#14629) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- esphome/components/api/api_frame_helper.h | 11 +++++++++++ .../components/api/api_frame_helper_noise.cpp | 18 ++++-------------- .../api/api_frame_helper_plaintext.cpp | 14 +++++++------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 2b4e9ea3cdb..151314658ea 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -232,6 +232,17 @@ class APIFrameHelper { EXPLICIT_REJECT = 8, // Noise only }; + // Fast inline state check for read_packet/write_protobuf_messages hot path. + // Returns OK only in DATA state; maps CLOSED/FAILED to BAD_STATE and any + // other intermediate state to WOULD_BLOCK. + inline APIError ESPHOME_ALWAYS_INLINE check_data_state_() const { + if (this->state_ == State::DATA) + return APIError::OK; + if (this->state_ == State::CLOSED || this->state_ == State::FAILED) + return APIError::BAD_STATE; + return APIError::WOULD_BLOCK; + } + // Containers (size varies, but typically 12+ bytes on 32-bit) std::array, API_MAX_SEND_QUEUE> tx_buf_; std::vector rx_buf_; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index ba4f2f0642d..62523fb8358 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -397,14 +397,9 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso state_ = orig_state; } APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { - APIError aerr = this->state_action_(); - if (aerr != APIError::OK) { + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) return aerr; - } - - if (this->state_ != State::DATA) { - return APIError::WOULD_BLOCK; - } aerr = this->try_read_frame_(); if (aerr != APIError::OK) @@ -461,14 +456,9 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuff } APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { - APIError aerr = state_action_(); - if (aerr != APIError::OK) { + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) return aerr; - } - - if (state_ != State::DATA) { - return APIError::WOULD_BLOCK; - } if (messages.empty()) { return APIError::OK; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index e2bb56e0acf..3c54ed7c70b 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -195,11 +195,11 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { } APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { - if (this->state_ != State::DATA) { - return APIError::WOULD_BLOCK; - } + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) + return aerr; - APIError aerr = this->try_read_frame_(); + aerr = this->try_read_frame_(); if (aerr != APIError::OK) { if (aerr == APIError::BAD_INDICATOR) { // Make sure to tell the remote that we don't @@ -244,9 +244,9 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWrite APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { - if (state_ != State::DATA) { - return APIError::BAD_STATE; - } + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) + return aerr; if (messages.empty()) { return APIError::OK; From cac751e9e8d1a185ede1f001bb19cc397a3b6a7f Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 9 Mar 2026 02:29:41 +0100 Subject: [PATCH 4/5] [nextion] Add configurable HTTP parameters for TFT upload (#14234) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/nextion/base_component.py | 3 + esphome/components/nextion/display.py | 66 +++++++++++++++++-- esphome/components/nextion/nextion.cpp | 12 ++++ esphome/components/nextion/nextion.h | 31 +++++++++ .../nextion/nextion_upload_arduino.cpp | 10 +-- .../nextion/nextion_upload_esp32.cpp | 8 ++- .../components/nextion/common_tft_upload.yaml | 5 ++ .../nextion/common_tft_upload_watchdog.yaml | 3 + tests/components/nextion/test.esp32-ard.yaml | 6 +- tests/components/nextion/test.esp32-idf.yaml | 6 +- .../components/nextion/test.esp8266-ard.yaml | 5 +- 11 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 tests/components/nextion/common_tft_upload.yaml create mode 100644 tests/components/nextion/common_tft_upload_watchdog.yaml diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 86551cbe238..7705b21b0b4 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -27,6 +27,9 @@ CONF_PRECISION = "precision" CONF_SKIP_CONNECTION_HANDSHAKE = "skip_connection_handshake" CONF_START_UP_PAGE = "start_up_page" CONF_STARTUP_OVERRIDE_MS = "startup_override_ms" +CONF_TFT_UPLOAD_HTTP_RETRIES = "tft_upload_http_retries" +CONF_TFT_UPLOAD_HTTP_TIMEOUT = "tft_upload_http_timeout" +CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT = "tft_upload_watchdog_timeout" CONF_TFT_URL = "tft_url" CONF_TOUCH_SLEEP_TIMEOUT = "touch_sleep_timeout" CONF_VARIABLE_NAME = "variable_name" diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 3bfcc959954..b8fcd5d8cfa 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -33,15 +33,24 @@ from .base_component import ( CONF_SKIP_CONNECTION_HANDSHAKE, CONF_START_UP_PAGE, CONF_STARTUP_OVERRIDE_MS, + CONF_TFT_UPLOAD_HTTP_RETRIES, + CONF_TFT_UPLOAD_HTTP_TIMEOUT, + CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT, CONF_TFT_URL, CONF_TOUCH_SLEEP_TIMEOUT, CONF_WAKE_UP_PAGE, ) CODEOWNERS = ["@senexcrenshaw", "@edwardtfn"] - DEPENDENCIES = ["uart"] -AUTO_LOAD = ["binary_sensor", "switch", "sensor", "text_sensor"] + + +def AUTO_LOAD() -> list[str]: + base = ["binary_sensor", "switch", "sensor", "text_sensor"] + if CORE.is_esp32: + base.append("watchdog") + return base + NextionSetBrightnessAction = nextion_ns.class_( "NextionSetBrightnessAction", automation.Action @@ -55,7 +64,24 @@ BufferOverflowTrigger = nextion_ns.class_( "BufferOverflowTrigger", automation.Trigger.template() ) -CONFIG_SCHEMA = ( + +def _validate_tft_upload(config): + has_tft_url = CONF_TFT_URL in config + for conf_key in ( + CONF_TFT_UPLOAD_HTTP_TIMEOUT, + CONF_TFT_UPLOAD_HTTP_RETRIES, + CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT, + ): + if conf_key in config and not has_tft_url: + raise cv.Invalid(f"{conf_key} requires {CONF_TFT_URL} to be set") + if CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT in config and not CORE.is_esp32: + raise cv.Invalid( + f"{CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT} is only available on ESP32" + ) + return config + + +CONFIG_SCHEMA = cv.All( display.BASIC_DISPLAY_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(Nextion), @@ -115,6 +141,14 @@ CONFIG_SCHEMA = ( ), ), cv.Optional(CONF_START_UP_PAGE): cv.uint8_t, + cv.Optional(CONF_TFT_UPLOAD_HTTP_RETRIES): cv.int_range(min=1, max=255), + cv.Optional(CONF_TFT_UPLOAD_HTTP_TIMEOUT): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=TimePeriod(milliseconds=65535)), + ), + cv.Optional( + CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT + ): cv.positive_time_period_milliseconds, cv.Optional(CONF_TFT_URL): cv.url, cv.Optional(CONF_TOUCH_SLEEP_TIMEOUT): cv.Any( 0, cv.int_range(min=3, max=65535) @@ -123,7 +157,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("5s")) - .extend(uart.UART_DEVICE_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + _validate_tft_upload, ) @@ -176,6 +211,29 @@ async def to_code(config): if CONF_TFT_URL in config: cg.add_define("USE_NEXTION_TFT_UPLOAD") cg.add(var.set_tft_url(config[CONF_TFT_URL])) + + # TFT upload HTTP timeout (default: 4.5s) + if CONF_TFT_UPLOAD_HTTP_TIMEOUT in config: + cg.add( + var.set_tft_upload_http_timeout( + config[CONF_TFT_UPLOAD_HTTP_TIMEOUT].total_milliseconds + ) + ) + + # TFT upload HTTP retries (default: 5) + if CONF_TFT_UPLOAD_HTTP_RETRIES in config: + cg.add( + var.set_tft_upload_http_retries(config[CONF_TFT_UPLOAD_HTTP_RETRIES]) + ) + + # TFT upload watchdog timeout (default: 0 = no adjustment) + if CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT in config: + cg.add( + var.set_tft_upload_watchdog_timeout( + config[CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT].total_milliseconds + ) + ) + if CORE.is_esp32: # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) esp32.include_builtin_idf_component("esp_http_client") diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index c8c1b6fa412..cb20c34005c 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -191,6 +191,18 @@ void Nextion::dump_config() { #ifdef USE_NEXTION_MAX_QUEUE_SIZE ESP_LOGCONFIG(TAG, " Max queue size: %zu", this->max_queue_size_); #endif +#ifdef USE_NEXTION_TFT_UPLOAD + ESP_LOGCONFIG(TAG, + " TFT URL: %s\n" + " TFT upload HTTP timeout: %" PRIu16 "ms\n" + " TFT upload HTTP retries: %u", + this->tft_url_.c_str(), this->tft_upload_http_timeout_, this->tft_upload_http_retries_); +#ifdef USE_ESP32 + if (this->tft_upload_watchdog_timeout_ > 0) { + ESP_LOGCONFIG(TAG, " TFT upload WDT timeout: %" PRIu32 "ms", this->tft_upload_watchdog_timeout_); + } +#endif // USE_ESP32 +#endif // USE_NEXTION_TFT_UPLOAD } void Nextion::update() { diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index c42ddba9b57..7999e3c4e3e 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1071,6 +1071,33 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe bool send_command_printf(const char *format, ...) __attribute__((format(printf, 2, 3))); #ifdef USE_NEXTION_TFT_UPLOAD + /** + * @brief Set the HTTP timeout for TFT upload requests. + * @param timeout_ms Timeout in milliseconds. Defaults to 4500ms (4.5s). + */ + void set_tft_upload_http_timeout(uint16_t timeout_ms) { this->tft_upload_http_timeout_ = timeout_ms; } + +#ifdef USE_ESP32 + /** + * @brief Set the watchdog timeout during TFT upload. + * + * The system watchdog timeout is temporarily adjusted to this value + * during the entire TFT transfer process and restored to the original + * value after the transfer completes (whether successful or not). + * + * A value of 0 means no watchdog adjustment (default). + * + * @param timeout_ms Watchdog timeout in milliseconds. 0 = no adjustment. + */ + void set_tft_upload_watchdog_timeout(uint32_t timeout_ms) { this->tft_upload_watchdog_timeout_ = timeout_ms; } +#endif // USE_ESP32 + + /** + * @brief Set the number of HTTP retries for TFT upload requests. + * @param retries Number of retries. Defaults to 5. Range: 1-255. + */ + void set_tft_upload_http_retries(uint8_t retries) { this->tft_upload_http_retries_ = retries; } + /** * Set the tft file URL. */ @@ -1439,8 +1466,12 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe int tft_size_ = 0; uint32_t original_baud_rate_ = 0; bool upload_first_chunk_sent_ = false; + uint16_t tft_upload_http_timeout_{4500}; ///< HTTP timeout in ms (default: 4.5s) + uint8_t tft_upload_http_retries_{5}; ///< HTTP retry count (default: 5) #ifdef USE_ESP32 + uint32_t tft_upload_watchdog_timeout_{0}; ///< WDT timeout in ms (0 = no adjustment) + /** * will request 4096 bytes chunks from the web server * and send each to Nextion diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 46a04c1b2e1..e03f1f470b2 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -166,7 +166,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Define the configuration for the HTTP client ESP_LOGV(TAG, "Init HTTP client, heap: %" PRIu32, EspClass::getFreeHeap()); HTTPClient http_client; - http_client.setTimeout(15000); // Yes 15 seconds.... Helps 8266s along + http_client.setTimeout(this->tft_upload_http_timeout_); bool begin_status = false; #ifdef USE_ESP8266 @@ -192,15 +192,15 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { http_client.collectHeaders(header_names, 1); ESP_LOGD(TAG, "URL: %s", this->tft_url_.c_str()); http_client.setReuse(true); - // try up to 5 times. DNS sometimes needs a second try or so + int tries = 1; int code = http_client.GET(); delay(100); // NOLINT App.feed_wdt(); - while (code != 200 && code != 206 && tries <= 5) { - ESP_LOGW(TAG, "HTTP fail: URL: %s; Error: %s, retry %d/5", this->tft_url_.c_str(), - HTTPClient::errorToString(code).c_str(), tries); + while (code != 200 && code != 206 && tries <= this->tft_upload_http_retries_) { + ESP_LOGW(TAG, "HTTP fail: URL: %s; Error: %s, retry %d/%u", this->tft_url_.c_str(), + HTTPClient::errorToString(code).c_str(), tries, this->tft_upload_http_retries_); delay(250); // NOLINT App.feed_wdt(); diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 43f59a8d4b4..1014c728a81 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -7,6 +7,7 @@ #include #include #include "esphome/components/network/util.h" +#include "esphome/components/watchdog/watchdog.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -68,7 +69,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r int partial_read_len = 0; uint8_t retries = 0; // Attempt to read the chunk with retries. - while (retries < 5 && read_len < buffer_size) { + while (retries < this->tft_upload_http_retries_ && read_len < buffer_size) { partial_read_len = esp_http_client_read(http_client, reinterpret_cast(buffer) + read_len, buffer_size - read_len); if (partial_read_len > 0) { @@ -167,6 +168,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return false; } + // Temporarily adjust watchdog timeout for the duration of the TFT upload + watchdog::WatchdogManager wdm(this->tft_upload_watchdog_timeout_); + this->connection_state_.is_updating_ = true; if (exit_reparse) { @@ -190,7 +194,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { .url = this->tft_url_.c_str(), .cert_pem = nullptr, .method = HTTP_METHOD_HEAD, - .timeout_ms = 15000, + .timeout_ms = static_cast(this->tft_upload_http_timeout_), .disable_auto_redirect = false, .max_redirection_count = 10, }; diff --git a/tests/components/nextion/common_tft_upload.yaml b/tests/components/nextion/common_tft_upload.yaml new file mode 100644 index 00000000000..190abbc7b19 --- /dev/null +++ b/tests/components/nextion/common_tft_upload.yaml @@ -0,0 +1,5 @@ +display: + - id: !extend main_lcd + tft_url: http://esphome.io/default35.tft + tft_upload_http_timeout: 20s + tft_upload_http_retries: 10 diff --git a/tests/components/nextion/common_tft_upload_watchdog.yaml b/tests/components/nextion/common_tft_upload_watchdog.yaml new file mode 100644 index 00000000000..385fee359e7 --- /dev/null +++ b/tests/components/nextion/common_tft_upload_watchdog.yaml @@ -0,0 +1,3 @@ +display: + - id: !extend main_lcd + tft_upload_watchdog_timeout: 30s diff --git a/tests/components/nextion/test.esp32-ard.yaml b/tests/components/nextion/test.esp32-ard.yaml index 7e94a9b4a5b..a4f71628dac 100644 --- a/tests/components/nextion/test.esp32-ard.yaml +++ b/tests/components/nextion/test.esp32-ard.yaml @@ -1,7 +1,5 @@ packages: uart: !include ../../test_build_components/common/uart/esp32-ard.yaml base: !include common.yaml - -display: - - id: !extend main_lcd - tft_url: http://esphome.io/default35.tft + tft_upload: !include common_tft_upload.yaml + tft_upload_watchdog: !include common_tft_upload_watchdog.yaml diff --git a/tests/components/nextion/test.esp32-idf.yaml b/tests/components/nextion/test.esp32-idf.yaml index 99820f0f8da..259b71c9d04 100644 --- a/tests/components/nextion/test.esp32-idf.yaml +++ b/tests/components/nextion/test.esp32-idf.yaml @@ -1,7 +1,5 @@ packages: uart: !include ../../test_build_components/common/uart/esp32-idf.yaml base: !include common.yaml - -display: - - id: !extend main_lcd - tft_url: http://esphome.io/default35.tft + tft_upload: !include common_tft_upload.yaml + tft_upload_watchdog: !include common_tft_upload_watchdog.yaml diff --git a/tests/components/nextion/test.esp8266-ard.yaml b/tests/components/nextion/test.esp8266-ard.yaml index 49f79b2f4c5..a2b0e727cce 100644 --- a/tests/components/nextion/test.esp8266-ard.yaml +++ b/tests/components/nextion/test.esp8266-ard.yaml @@ -1,7 +1,4 @@ packages: uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml base: !include common.yaml - -display: - - id: !extend main_lcd - tft_url: http://esphome.io/default35.tft + tft_upload: !include common_tft_upload.yaml From 5b9cab02bee18da518c522fbdcf26b0b12fb17f6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:37:54 -0400 Subject: [PATCH 5/5] [multiple] Add default initializers to uninitialized member variables (#14636) Co-authored-by: Claude Opus 4.6 --- esphome/components/duty_time/duty_time_sensor.h | 6 +++--- esphome/components/growatt_solar/growatt_solar.h | 4 ++-- esphome/components/hte501/hte501.h | 4 ++-- esphome/components/select/select_call.h | 2 +- esphome/components/uponor_smatrix/uponor_smatrix.h | 4 ++-- esphome/components/wifi_info/wifi_info_text_sensor.h | 2 +- esphome/components/x9c/x9c.h | 6 +++--- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index d9fb2a6d604..d21802ebb6c 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -41,9 +41,9 @@ class DutyTimeSensor : public sensor::Sensor, public PollingComponent { sensor::Sensor *last_duty_time_sensor_{nullptr}; ESPPreferenceObject pref_; - uint32_t total_sec_; - uint32_t last_time_; - uint32_t edge_time_; + uint32_t total_sec_{0}; + uint32_t last_time_{0}; + uint32_t edge_time_{0}; bool last_state_{false}; bool restore_; }; diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index b0ddd4b99d8..833d6a36ddb 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -56,8 +56,8 @@ class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { } protected: - bool waiting_to_update_; - uint32_t last_send_; + bool waiting_to_update_{false}; + uint32_t last_send_{0}; struct GrowattPhase { sensor::Sensor *voltage_sensor_{nullptr}; diff --git a/esphome/components/hte501/hte501.h b/esphome/components/hte501/hte501.h index b47daf9157c..7f29885f49d 100644 --- a/esphome/components/hte501/hte501.h +++ b/esphome/components/hte501/hte501.h @@ -18,8 +18,8 @@ class HTE501Component : public PollingComponent, public i2c::I2CDevice { void update() override; protected: - sensor::Sensor *temperature_sensor_; - sensor::Sensor *humidity_sensor_; + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, CRC_CHECK_FAILED } error_code_{NONE}; }; diff --git a/esphome/components/select/select_call.h b/esphome/components/select/select_call.h index c9abbc69a0b..fbe7b82e929 100644 --- a/esphome/components/select/select_call.h +++ b/esphome/components/select/select_call.h @@ -43,7 +43,7 @@ class SelectCall { Select *const parent_; optional index_; SelectOperation operation_{SELECT_OP_NONE}; - bool cycle_; + bool cycle_{false}; }; } // namespace esphome::select diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.h b/esphome/components/uponor_smatrix/uponor_smatrix.h index bd760f0d77d..bd20e9b6a0a 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.h +++ b/esphome/components/uponor_smatrix/uponor_smatrix.h @@ -89,8 +89,8 @@ class UponorSmatrixComponent : public uart::UARTDevice, public Component { std::vector rx_buffer_; std::queue> tx_queue_; - uint32_t last_rx_; - uint32_t last_tx_; + uint32_t last_rx_{0}; + uint32_t last_tx_{0}; #ifdef USE_TIME time::RealTimeClock *time_id_{nullptr}; diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 8ef35a5f5d1..7ade170c022 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -23,7 +23,7 @@ class IPAddressWiFiInfo final : public Component, public text_sensor::TextSensor const network::IPAddress &dns2) override; protected: - std::array ip_sensors_; + std::array ip_sensors_{}; }; class DNSAddressWifiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener { diff --git a/esphome/components/x9c/x9c.h b/esphome/components/x9c/x9c.h index 66c3df14e19..7dcd79bb7ce 100644 --- a/esphome/components/x9c/x9c.h +++ b/esphome/components/x9c/x9c.h @@ -25,9 +25,9 @@ class X9cOutput : public output::FloatOutput, public Component { InternalGPIOPin *cs_pin_; InternalGPIOPin *inc_pin_; InternalGPIOPin *ud_pin_; - float initial_value_; - float pot_value_; - int step_delay_; + float initial_value_{0.0f}; + float pot_value_{0.0f}; + int step_delay_{0}; }; } // namespace x9c