From 608f834eaab1d22d0723e1229ec6ce3a4e419d47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 00:49:29 +0100 Subject: [PATCH 01/57] [ci] Isolate usb_cdc_acm in component tests due to tinyusb/usb_host conflict (#12392) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 27a36f889f..427602dff2 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -87,6 +87,7 @@ ISOLATED_COMPONENTS = { "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", "tinyusb": "Conflicts with usb_host component - cannot be used together", + "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } From 3a6edbc2c74c7dbf1451d51cb162a155d6487917 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 00:49:44 +0100 Subject: [PATCH 02/57] [micronova] Fix test UART package key to match directory name (#12391) --- tests/components/micronova/test.esp32-idf.yaml | 2 +- tests/components/micronova/test.esp8266-ard.yaml | 2 +- tests/components/micronova/test.rp2040-ard.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/micronova/test.esp32-idf.yaml b/tests/components/micronova/test.esp32-idf.yaml index b3e4714bc3..6e5602818f 100644 --- a/tests/components/micronova/test.esp32-idf.yaml +++ b/tests/components/micronova/test.esp32-idf.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO13 packages: - uart: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/micronova/test.esp8266-ard.yaml b/tests/components/micronova/test.esp8266-ard.yaml index 04030801e3..80792813ad 100644 --- a/tests/components/micronova/test.esp8266-ard.yaml +++ b/tests/components/micronova/test.esp8266-ard.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO15 packages: - uart: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/micronova/test.rp2040-ard.yaml b/tests/components/micronova/test.rp2040-ard.yaml index 67110f25b0..f069760378 100644 --- a/tests/components/micronova/test.rp2040-ard.yaml +++ b/tests/components/micronova/test.rp2040-ard.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO3 packages: - uart: !include ../../test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml <<: !include common.yaml From 36423994600afcbe276f55a4267d44a09c16a3b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 00:50:26 +0100 Subject: [PATCH 03/57] [tests] Fix clang-tidy warnings in custom_api_device_component fixture (#12390) --- .../custom_api_device_component/custom_api_device_component.cpp | 1 + .../custom_api_device_component/custom_api_device_component.h | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp index 01bc7dcd98..c86ab99242 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp @@ -52,6 +52,7 @@ void CustomAPIDeviceComponent::on_service_with_arrays(std::vector bool_arr } } +// NOLINTNEXTLINE(performance-unnecessary-value-param) void CustomAPIDeviceComponent::on_ha_state_changed(std::string entity_id, std::string state) { ESP_LOGI(TAG, "Home Assistant state changed for %s: %s", entity_id.c_str(), state.c_str()); ESP_LOGI(TAG, "This subscription uses std::string API for backward compatibility"); diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h index 0720b9e7de..4d519d3ed1 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h @@ -24,6 +24,7 @@ class CustomAPIDeviceComponent : public Component, public CustomAPIDevice { std::vector float_array, std::vector string_array); // Test Home Assistant state subscription with std::string API + // NOLINTNEXTLINE(performance-unnecessary-value-param) void on_ha_state_changed(std::string entity_id, std::string state); }; From 9f2693ead5405aa653fce7f8451428666f6b958c Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Wed, 10 Dec 2025 00:59:58 +0100 Subject: [PATCH 04/57] [core] Packages refactor and conditional package inclusion (package refactor part 1) (#11605) Co-authored-by: J. Nick Koston --- esphome/components/packages/__init__.py | 151 ++++++++--- esphome/config.py | 10 +- .../component_tests/packages/test_packages.py | 238 ++++++++++++++++-- .../06-package_merging.approved.yaml | 43 ++++ .../06-package_merging.input.yaml | 61 +++++ tests/unit_tests/test_substitutions.py | 6 +- 6 files changed, 446 insertions(+), 63 deletions(-) create mode 100644 tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 15ab11d6b0..6d353ccf11 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,5 +1,9 @@ +from collections import UserDict +from collections.abc import Callable +from functools import reduce import logging from pathlib import Path +from typing import Any from esphome import git, yaml_util from esphome.components.substitutions.jinja import has_jinja @@ -15,6 +19,7 @@ from esphome.const import ( CONF_PATH, CONF_REF, CONF_REFRESH, + CONF_SUBSTITUTIONS, CONF_URL, CONF_USERNAME, CONF_VARS, @@ -27,32 +32,43 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = CONF_PACKAGES -def valid_package_contents(package_config: dict): - """Validates that a package_config that will be merged looks as much as possible to a valid config - to fail early on obvious mistakes.""" - if isinstance(package_config, dict): - if CONF_URL in package_config: - # If a URL key is found, then make sure the config conforms to a remote package schema: - return REMOTE_PACKAGE_SCHEMA(package_config) +def validate_has_jinja(value: Any): + if not isinstance(value, str) or not has_jinja(value): + raise cv.Invalid("string does not contain Jinja syntax") + return value - # Validate manually since Voluptuous would regenerate dicts and lose metadata - # such as ESPHomeDataBase - for k, v in package_config.items(): - if not isinstance(k, str): - raise cv.Invalid("Package content keys must be strings") - if isinstance(v, (dict, list, Remove)): - continue # e.g. script: [], psram: !remove, logger: {level: debug} - if v is None: - continue # e.g. web_server: - if isinstance(v, str) and has_jinja(v): - # e.g: remote package shorthand: - # package_name: github://esphome/repo/file.yaml@${ branch } - continue - raise cv.Invalid("Invalid component content in package definition") - return package_config +def valid_package_contents(allow_jinja: bool = True) -> Callable[[Any], dict]: + """Returns a validator that checks if a package_config that will be merged looks as + much as possible to a valid config to fail early on obvious mistakes.""" - raise cv.Invalid("Package contents must be a dict") + def validator(package_config: dict) -> dict: + if isinstance(package_config, dict): + if CONF_URL in package_config: + # If a URL key is found, then make sure the config conforms to a remote package schema: + return REMOTE_PACKAGE_SCHEMA(package_config) + + # Validate manually since Voluptuous would regenerate dicts and lose metadata + # such as ESPHomeDataBase + for k, v in package_config.items(): + if not isinstance(k, str): + raise cv.Invalid("Package content keys must be strings") + if isinstance(v, (dict, list, Remove)): + continue # e.g. script: [], psram: !remove, logger: {level: debug} + if v is None: + continue # e.g. web_server: + if allow_jinja and isinstance(v, str) and has_jinja(v): + # e.g: remote package shorthand: + # package_name: github://esphome/repo/file.yaml@${ branch }, or: + # switch: ${ expression that evals to a switch } + continue + + raise cv.Invalid("Invalid component content in package definition") + return package_config + + raise cv.Invalid("Package contents must be a dict") + + return validator def expand_file_to_files(config: dict): @@ -142,7 +158,10 @@ REMOTE_PACKAGE_SCHEMA = cv.All( PACKAGE_SCHEMA = cv.Any( # A package definition is either: validate_source_shorthand, # A git URL shorthand string that expands to a remote package schema, or REMOTE_PACKAGE_SCHEMA, # a valid remote package schema, or - valid_package_contents, # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}} + validate_has_jinja, # a Jinja string that may resolve to a package, or + valid_package_contents( + allow_jinja=True + ), # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}} # which will have to be fully validated later as per each component's schema. ) @@ -235,32 +254,84 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict: return {"packages": packages} -def _process_package(package_config, config, skip_update: bool = False): - recursive_package = package_config - if CONF_URL in package_config: - package_config = _process_remote_package(package_config, skip_update) - if isinstance(package_config, dict): - recursive_package = do_packages_pass(package_config, skip_update) - return merge_config(recursive_package, config) - - -def do_packages_pass(config: dict, skip_update: bool = False): +def _walk_packages( + config: dict, callback: Callable[[dict], dict], validate_deprecated: bool = True +) -> dict: if CONF_PACKAGES not in config: return config packages = config[CONF_PACKAGES] - with cv.prepend_path(CONF_PACKAGES): + + # The following block and `validate_deprecated` parameter can be safely removed + # once single-package deprecation is effective + if validate_deprecated: packages = CONFIG_SCHEMA(packages) + + with cv.prepend_path(CONF_PACKAGES): if isinstance(packages, dict): for package_name, package_config in reversed(packages.items()): with cv.prepend_path(package_name): - config = _process_package(package_config, config, skip_update) + package_config = callback(package_config) + packages[package_name] = _walk_packages(package_config, callback) elif isinstance(packages, list): - for package_config in reversed(packages): - config = _process_package(package_config, config, skip_update) + for idx in reversed(range(len(packages))): + with cv.prepend_path(idx): + package_config = callback(packages[idx]) + packages[idx] = _walk_packages(package_config, callback) else: raise cv.Invalid( f"Packages must be a key to value mapping or list, got {type(packages)} instead" ) - - del config[CONF_PACKAGES] + config[CONF_PACKAGES] = packages + return config + + +def do_packages_pass(config: dict, skip_update: bool = False) -> dict: + """Processes, downloads and validates all packages in the config. + Also extracts and merges all substitutions found in packages into the main config substitutions. + """ + if CONF_PACKAGES not in config: + return config + + substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {})) + + def process_package_callback(package_config: dict) -> dict: + """This will be called for each package found in the config.""" + package_config = PACKAGE_SCHEMA(package_config) + if isinstance(package_config, str): + return package_config # Jinja string, skip processing + if CONF_URL in package_config: + package_config = _process_remote_package(package_config, skip_update) + # Extract substitutions from the package and merge them into the main substitutions: + substitutions.data = merge_config( + package_config.pop(CONF_SUBSTITUTIONS, {}), substitutions.data + ) + return package_config + + _walk_packages(config, process_package_callback) + + if substitutions: + config[CONF_SUBSTITUTIONS] = substitutions.data + + return config + + +def merge_packages(config: dict) -> dict: + """Merges all packages into the main config and removes the `packages:` key.""" + if CONF_PACKAGES not in config: + return config + + # Build flat list of all package configs to merge in priority order: + merge_list: list[dict] = [] + + validate_package = valid_package_contents(allow_jinja=False) + + def process_package_callback(package_config: dict) -> dict: + """This will be called for each package found in the config.""" + merge_list.append(validate_package(package_config)) + return package_config + + _walk_packages(config, process_package_callback, validate_deprecated=False) + # Merge all packages into the main config: + config = reduce(lambda new, old: merge_config(old, new), merge_list, config) + del config[CONF_PACKAGES] return config diff --git a/esphome/config.py b/esphome/config.py index 1c4cdd93c6..694716be34 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1012,14 +1012,20 @@ def validate_config( CORE.raw_config = config - # 1.1. Resolve !extend and !remove and check for REPLACEME + # 1.1. Merge packages + if CONF_PACKAGES in config: + from esphome.components.packages import merge_packages + + config = merge_packages(config) + + # 1.2. Resolve !extend and !remove and check for REPLACEME # After this step, there will not be any Extend or Remove values in the config anymore try: resolve_extend_remove(config) except vol.Invalid as err: result.add_error(err) - # 1.2. Load external_components + # 1.3. Load external_components if CONF_EXTERNAL_COMPONENTS in config: from esphome.components.external_components import do_external_components_pass diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 34760587df..3829e540d7 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass +from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv @@ -27,6 +27,7 @@ from esphome.const import ( CONF_REFRESH, CONF_SENSOR, CONF_SSID, + CONF_SUBSTITUTIONS, CONF_UPDATE_INTERVAL, CONF_URL, CONF_VARS, @@ -68,11 +69,12 @@ def fixture_basic_esphome(): def packages_pass(config): """Wrapper around packages_pass that also resolves Extend and Remove.""" config = do_packages_pass(config) + config = merge_packages(config) resolve_extend_remove(config) return config -def test_package_unused(basic_esphome, basic_wifi): +def test_package_unused(basic_esphome, basic_wifi) -> None: """ Ensures do_package_pass does not change a config if packages aren't used. """ @@ -82,7 +84,7 @@ def test_package_unused(basic_esphome, basic_wifi): assert actual == config -def test_package_invalid_dict(basic_esphome, basic_wifi): +def test_package_invalid_dict(basic_esphome, basic_wifi) -> None: """ If a url: key is present, it's expected to be well-formed remote package spec. Ensure an error is raised if not. Any other simple dict passed as a package will be merged as usual but may fail later validation. @@ -107,7 +109,7 @@ def test_package_invalid_dict(basic_esphome, basic_wifi): ], ], ) -def test_package_shorthand(packages): +def test_package_shorthand(packages) -> None: CONFIG_SCHEMA(packages) @@ -133,12 +135,12 @@ def test_package_shorthand(packages): [3], ], ) -def test_package_invalid(packages): +def test_package_invalid(packages) -> None: with pytest.raises(cv.Invalid): CONFIG_SCHEMA(packages) -def test_package_include(basic_wifi, basic_esphome): +def test_package_include(basic_wifi, basic_esphome) -> None: """ Tests the simple case where an independent config present in a package is added to the top-level config as is. @@ -159,7 +161,7 @@ def test_single_package( basic_esphome, basic_wifi, caplog: pytest.LogCaptureFixture, -): +) -> None: """ Tests the simple case where a single package is added to the top-level config as is. In this test, the CONF_WIFI config is expected to be simply added to the top-level config. @@ -179,7 +181,7 @@ def test_single_package( assert "This method for including packages will go away in 2026.7.0" in caplog.text -def test_package_append(basic_wifi, basic_esphome): +def test_package_append(basic_wifi, basic_esphome) -> None: """ Tests the case where a key is present in both a package and top-level config. @@ -204,7 +206,7 @@ def test_package_append(basic_wifi, basic_esphome): assert actual == expected -def test_package_override(basic_wifi, basic_esphome): +def test_package_override(basic_wifi, basic_esphome) -> None: """ Ensures that the top-level configuration takes precedence over duplicate keys defined in a package. @@ -228,7 +230,7 @@ def test_package_override(basic_wifi, basic_esphome): assert actual == expected -def test_multiple_package_order(): +def test_multiple_package_order() -> None: """ Ensures that mutiple packages are merged in order. """ @@ -257,7 +259,7 @@ def test_multiple_package_order(): assert actual == expected -def test_package_list_merge(): +def test_package_list_merge() -> None: """ Ensures lists defined in both a package and the top-level config are merged correctly """ @@ -313,7 +315,7 @@ def test_package_list_merge(): assert actual == expected -def test_package_list_merge_by_id(): +def test_package_list_merge_by_id() -> None: """ Ensures that components with matching IDs are merged correctly. @@ -391,7 +393,7 @@ def test_package_list_merge_by_id(): assert actual == expected -def test_package_merge_by_id_with_list(): +def test_package_merge_by_id_with_list() -> None: """ Ensures that components with matching IDs are merged correctly when their configuration contains lists. @@ -430,7 +432,7 @@ def test_package_merge_by_id_with_list(): assert actual == expected -def test_package_merge_by_missing_id(): +def test_package_merge_by_missing_id() -> None: """ Ensures that a validation error is thrown when trying to extend a missing ID. """ @@ -466,7 +468,7 @@ def test_package_merge_by_missing_id(): assert error_raised -def test_package_list_remove_by_id(): +def test_package_list_remove_by_id() -> None: """ Ensures that components with matching IDs are removed correctly. @@ -517,7 +519,7 @@ def test_package_list_remove_by_id(): assert actual == expected -def test_multiple_package_list_remove_by_id(): +def test_multiple_package_list_remove_by_id() -> None: """ Ensures that components with matching IDs are removed correctly. @@ -563,7 +565,7 @@ def test_multiple_package_list_remove_by_id(): assert actual == expected -def test_package_dict_remove_by_id(basic_wifi, basic_esphome): +def test_package_dict_remove_by_id(basic_wifi, basic_esphome) -> None: """ Ensures that components with missing IDs are removed from dict. Ensures that the top-level configuration takes precedence over duplicate keys defined in a package. @@ -584,7 +586,7 @@ def test_package_dict_remove_by_id(basic_wifi, basic_esphome): assert actual == expected -def test_package_remove_by_missing_id(): +def test_package_remove_by_missing_id() -> None: """ Ensures that components with missing IDs are not merged. """ @@ -632,7 +634,7 @@ def test_package_remove_by_missing_id(): @patch("esphome.git.clone_or_update") def test_remote_packages_with_files_list( mock_clone_or_update, mock_is_file, mock_load_yaml -): +) -> None: """ Ensures that packages are loaded as mixed list of dictionary and strings """ @@ -704,7 +706,7 @@ def test_remote_packages_with_files_list( @patch("esphome.git.clone_or_update") def test_remote_packages_with_files_and_vars( mock_clone_or_update, mock_is_file, mock_load_yaml -): +) -> None: """ Ensures that packages are loaded as mixed list of dictionary and strings with vars """ @@ -793,3 +795,199 @@ def test_remote_packages_with_files_and_vars( actual = packages_pass(config) assert actual == expected + + +def test_packages_merge_substitutions() -> None: + """ + Tests that substitutions from packages in a complex package hierarchy + are extracted and merged into the top-level config. + """ + config = { + CONF_SUBSTITUTIONS: { + "a": 1, + "b": 2, + "c": 3, + }, + CONF_PACKAGES: { + "package1": { + "logger": { + "level": "DEBUG", + }, + CONF_PACKAGES: [ + { + CONF_SUBSTITUTIONS: { + "a": 10, + "e": 5, + }, + "sensor": [ + {"platform": "template", "id": "sensor1"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor2"}, + ], + }, + "package2": { + "logger": { + "level": "VERBOSE", + }, + }, + "package3": { + CONF_PACKAGES: [ + { + CONF_PACKAGES: [ + { + CONF_SUBSTITUTIONS: { + "b": 20, + "d": 4, + }, + "sensor": [ + {"platform": "template", "id": "sensor3"}, + ], + }, + ], + CONF_SUBSTITUTIONS: { + "b": 20, + "d": 6, + }, + "sensor": [ + {"platform": "template", "id": "sensor4"}, + ], + }, + ], + }, + }, + } + + expected = { + CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3}, + CONF_PACKAGES: { + "package1": { + "logger": { + "level": "DEBUG", + }, + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor1"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor2"}, + ], + }, + "package2": { + "logger": { + "level": "VERBOSE", + }, + }, + "package3": { + CONF_PACKAGES: [ + { + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor3"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor4"}, + ], + }, + ], + }, + }, + } + + actual = do_packages_pass(config) + assert actual == expected + + +def test_package_merge() -> None: + """ + Tests that all packages are merged into the top-level config. + """ + config = { + CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3}, + CONF_PACKAGES: { + "package1": { + "logger": { + "level": "DEBUG", + }, + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor1"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor2"}, + ], + }, + "package2": { + "logger": { + "level": "VERBOSE", + }, + }, + "package3": { + CONF_PACKAGES: [ + { + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor3"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor4"}, + ], + }, + ], + }, + }, + } + expected = { + "sensor": [ + {"platform": "template", "id": "sensor1"}, + {"platform": "template", "id": "sensor2"}, + {"platform": "template", "id": "sensor3"}, + {"platform": "template", "id": "sensor4"}, + ], + "logger": {"level": "VERBOSE"}, + CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3}, + } + actual = merge_packages(config) + + assert actual == expected + + +@pytest.mark.parametrize( + "invalid_package", + [ + 6, + "some string", + ["some string"], + None, + True, + {"some_component": 8}, + {3: 2}, + {"some_component": r"${unevaluated expression}"}, + ], +) +def test_package_merge_invalid(invalid_package) -> None: + """ + Tests that trying to merge an invalid package raises an error. + """ + config = { + CONF_PACKAGES: { + "some_package": invalid_package, + }, + } + + with pytest.raises(cv.Invalid): + merge_packages(config) diff --git a/tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml b/tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml new file mode 100644 index 0000000000..3fbf5660d5 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml @@ -0,0 +1,43 @@ +fancy_component: &id001 + - id: component9 + value: 9 +some_component: + - id: component1 + value: 1 + - id: component2 + value: 2 + - id: component3 + value: 3 + - id: component4 + value: 4 + - id: component5 + value: 79 + power: 200 + - id: component6 + value: 6 + - id: component7 + value: 7 +switch: &id002 + - platform: gpio + id: switch1 + pin: 12 + - platform: gpio + id: switch2 + pin: 13 +display: + - platform: ili9xxx + dimensions: + width: 100 + height: 480 +substitutions: + extended_component: component5 + package_options: + alternative_package: + alternative_component: + - id: component8 + value: 8 + fancy_package: + fancy_component: *id001 + pin: 12 + some_switches: *id002 + package_selection: fancy_package diff --git a/tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml b/tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml new file mode 100644 index 0000000000..d937a89306 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml @@ -0,0 +1,61 @@ +substitutions: + package_options: + alternative_package: + alternative_component: + - id: component8 + value: 8 + fancy_package: + fancy_component: + - id: component9 + value: 9 + + pin: 12 + some_switches: + - platform: gpio + id: switch1 + pin: ${pin} + - platform: gpio + id: switch2 + pin: ${pin+1} + + package_selection: fancy_package + +packages: + - ${ package_options[package_selection] } + - some_component: + - id: component1 + value: 1 + - some_component: + - id: component2 + value: 2 + - switch: ${ some_switches } + - packages: + package_with_defaults: !include + file: display.yaml + vars: + native_width: 100 + high_dpi: false + my_package: + packages: + - packages: + special_package: + substitutions: + extended_component: component5 + some_component: + - id: component3 + value: 3 + some_component: + - id: component4 + value: 4 + - id: !extend ${ extended_component } + power: 200 + value: 79 + some_component: + - id: component5 + value: 5 + +some_component: + - id: component6 + value: 6 + - id: component7 + value: 7 diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index cba1e398c3..1d8cb7631d 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -8,7 +8,7 @@ import pytest from esphome import config as config_module, yaml_util from esphome.components import substitutions -from esphome.components.packages import do_packages_pass +from esphome.components.packages import do_packages_pass, merge_packages from esphome.config import resolve_extend_remove from esphome.config_helpers import merge_config from esphome.const import CONF_SUBSTITUTIONS @@ -74,6 +74,8 @@ def verify_database(value: Any, path: str = "") -> str | None: return None if isinstance(value, dict): for k, v in value.items(): + if path == "" and k == CONF_SUBSTITUTIONS: + return None # ignore substitutions key at top level since it is merged. key_result = verify_database(k, f"{path}/{k}") if key_result is not None: return key_result @@ -144,6 +146,8 @@ def test_substitutions_fixtures( substitutions.do_substitution_pass(config, command_line_substitutions) + config = merge_packages(config) + resolve_extend_remove(config) verify_database_result = verify_database(config) if verify_database_result is not None: From 26770e09dcfda0df89f9e2448b087b019539db58 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:08:35 -0500 Subject: [PATCH 05/57] Bump version to 2025.12.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a19120b9da..ecb156d1f3 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.12.0-dev +PROJECT_NUMBER = 2025.12.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 8fa2d8da16..93dd39b982 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.12.0-dev" +__version__ = "2025.12.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 84d5348bd877a4c88fd532a6c5f9427443a13d5c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:08:35 -0500 Subject: [PATCH 06/57] Bump version to 2026.1.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a19120b9da..503979b61e 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.12.0-dev +PROJECT_NUMBER = 2026.1.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 8fa2d8da16..c94ead0be4 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.12.0-dev" +__version__ = "2026.1.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 03c391bd43e599944ca7d1f49c1750fabbd6f271 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:19:29 +0100 Subject: [PATCH 07/57] [light] Add zero-copy support for API effect commands (#12384) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_pb2.cpp | 7 +++++-- esphome/components/api/api_pb2.h | 5 +++-- esphome/components/api/api_pb2_dump.cpp | 4 +++- esphome/components/light/light_call.cpp | 9 +++++---- esphome/components/light/light_call.h | 4 +++- 7 files changed, 21 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 2534ad0b1f..50af5061c0 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -579,7 +579,7 @@ message LightCommandRequest { bool has_flash_length = 16; uint32 flash_length = 17; bool has_effect = 18; - string effect = 19; + string effect = 19 [(pointer_to_buffer) = true]; uint32 device_id = 28 [(field_ifdef) = "USE_DEVICES"]; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4b10610281..09b311c1e4 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -533,7 +533,7 @@ void APIConnection::light_command(const LightCommandRequest &msg) { if (msg.has_flash_length) call.set_flash_length(msg.flash_length); if (msg.has_effect) - call.set_effect(msg.effect); + call.set_effect(reinterpret_cast(msg.effect), msg.effect_len); call.perform(); } #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 128f82fe7f..4a89ee78e1 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -611,9 +611,12 @@ bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 19: - this->effect = value.as_string(); + case 19: { + // Use raw data directly to avoid allocation + this->effect = value.data(); + this->effect_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 49f1ea3c52..f23a62fc3c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -840,7 +840,7 @@ class LightStateResponse final : public StateResponseProtoMessage { class LightCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 32; - static constexpr uint8_t ESTIMATED_SIZE = 112; + static constexpr uint8_t ESTIMATED_SIZE = 122; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "light_command_request"; } #endif @@ -869,7 +869,8 @@ class LightCommandRequest final : public CommandProtoMessage { bool has_flash_length{false}; uint32_t flash_length{0}; bool has_effect{false}; - std::string effect{}; + const uint8_t *effect{nullptr}; + uint16_t effect_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ca69d1ff00..5e271f41cb 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -999,7 +999,9 @@ void LightCommandRequest::dump_to(std::string &out) const { dump_field(out, "has_flash_length", this->has_flash_length); dump_field(out, "flash_length", this->flash_length); dump_field(out, "has_effect", this->has_effect); - dump_field(out, "effect", this->effect); + out.append(" effect: "); + out.append(format_hex_pretty(this->effect, this->effect_len)); + out.append("\n"); #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index dca5861734..8161e8b814 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -504,8 +504,8 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() { #undef KEY } -LightCall &LightCall::set_effect(const std::string &effect) { - if (strcasecmp(effect.c_str(), "none") == 0) { +LightCall &LightCall::set_effect(const char *effect, size_t len) { + if (len == 4 && strncasecmp(effect, "none", 4) == 0) { this->set_effect(0); return *this; } @@ -513,15 +513,16 @@ LightCall &LightCall::set_effect(const std::string &effect) { bool found = false; for (uint32_t i = 0; i < this->parent_->effects_.size(); i++) { LightEffect *e = this->parent_->effects_[i]; + const char *name = e->get_name(); - if (strcasecmp(effect.c_str(), e->get_name()) == 0) { + if (strncasecmp(effect, name, len) == 0 && name[len] == '\0') { this->set_effect(i + 1); found = true; break; } } if (!found) { - ESP_LOGW(TAG, "'%s': no such effect '%s'", this->parent_->get_name().c_str(), effect.c_str()); + ESP_LOGW(TAG, "'%s': no such effect '%.*s'", this->parent_->get_name().c_str(), (int) len, effect); } return *this; } diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 6931b58b9d..0926ab6108 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -129,7 +129,9 @@ class LightCall { /// Set the effect of the light by its name. LightCall &set_effect(optional effect); /// Set the effect of the light by its name. - LightCall &set_effect(const std::string &effect); + LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); } + /// Set the effect of the light by its name and length (zero-copy from API). + LightCall &set_effect(const char *effect, size_t len); /// Set the effect of the light by its internal index number (only for internal use). LightCall &set_effect(uint32_t effect_number); LightCall &set_effect(optional effect_number); From d0fbc82f470a4bcbdc67b58d5596a712e3c426fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:19:52 +0100 Subject: [PATCH 08/57] [esp32_ble_client] Use stack-based MAC formatting in auth logging (#12393) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32_ble_client/ble_client_base.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 07e88c7528..a09390c747 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -524,10 +524,9 @@ void BLEClientBase::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_ case ESP_GAP_BLE_AUTH_CMPL_EVT: if (!this->check_addr(param->ble_security.auth_cmpl.bd_addr)) return; - esp_bd_addr_t bd_addr; - memcpy(bd_addr, param->ble_security.auth_cmpl.bd_addr, sizeof(esp_bd_addr_t)); - ESP_LOGI(TAG, "[%d] [%s] auth complete addr: %s", this->connection_index_, this->address_str_, - format_hex(bd_addr, 6).c_str()); + char addr_str[MAC_ADDR_STR_LEN]; + format_mac_addr_upper(param->ble_security.auth_cmpl.bd_addr, addr_str); + ESP_LOGI(TAG, "[%d] [%s] auth complete addr: %s", this->connection_index_, this->address_str_, addr_str); if (!param->ble_security.auth_cmpl.success) { this->log_error_("auth fail reason", param->ble_security.auth_cmpl.fail_reason); } else { From b1f9100b0283838b160b61033a5e45c0fdb58428 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:20:08 +0100 Subject: [PATCH 09/57] [core] Add constexpr parse_hex_char helper and simplify parse_hex (#12394) --- esphome/core/helpers.cpp | 13 +++---------- esphome/core/helpers.h | 11 +++++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 77102c8db2..6a4894419c 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -266,19 +266,12 @@ std::string make_name_with_suffix(const std::string &name, char sep, const char // Parsing & formatting size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { - uint8_t val; size_t chars = std::min(length, 2 * count); for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) { - if (*str >= '0' && *str <= '9') { - val = *str - '0'; - } else if (*str >= 'A' && *str <= 'F') { - val = 10 + (*str - 'A'); - } else if (*str >= 'a' && *str <= 'f') { - val = 10 + (*str - 'a'); - } else { + uint8_t val = parse_hex_char(*str); + if (val > 15) return 0; - } - data[i >> 1] = !(i & 1) ? val << 4 : data[i >> 1] | val; + data[i >> 1] = (i & 1) ? data[i >> 1] | val : val << 4; } return chars; } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 6054f03353..3e44e08dd4 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -624,6 +624,17 @@ template::value, int> = 0> optional< return parse_hex(str.c_str(), str.length()); } +/// Parse a hex character to its nibble value (0-15), returns 255 on invalid input +constexpr uint8_t parse_hex_char(char c) { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + return 255; +} + /// Convert a nibble (0-15) to lowercase hex char inline char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } From 567e82cfec7f448e03347e20ece6ade58e356bb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:20:23 +0100 Subject: [PATCH 10/57] [api] Fix potential buffer overflow in noise PSK base64 decode (#12395) --- esphome/components/api/api_connection.cpp | 2 +- esphome/core/helpers.cpp | 44 +++++++++++++++-------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 09b311c1e4..5186e5afda 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1669,7 +1669,7 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption } else { ESP_LOGW(TAG, "Failed to clear encryption key"); } - } else if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) { + } else if (base64_decode(msg.key, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 6a4894419c..fb96869d21 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -473,22 +473,13 @@ std::string base64_encode(const uint8_t *buf, size_t buf_len) { } size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { - std::vector decoded = base64_decode(encoded_string); - if (decoded.size() > buf_len) { - ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); - decoded.resize(buf_len); - } - memcpy(buf, decoded.data(), decoded.size()); - return decoded.size(); -} - -std::vector base64_decode(const std::string &encoded_string) { int in_len = encoded_string.size(); int i = 0; int j = 0; int in = 0; + size_t out = 0; uint8_t char_array_4[4], char_array_3[3]; - std::vector ret; + bool truncated = false; // SAFETY: The loop condition checks is_base64() before processing each character. // This ensures base64_find_char() is only called on valid base64 characters, @@ -504,8 +495,13 @@ std::vector base64_decode(const std::string &encoded_string) { char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - for (i = 0; (i < 3); i++) - ret.push_back(char_array_3[i]); + for (i = 0; i < 3; i++) { + if (out < buf_len) { + buf[out++] = char_array_3[i]; + } else { + truncated = true; + } + } i = 0; } } @@ -521,10 +517,28 @@ std::vector base64_decode(const std::string &encoded_string) { char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - for (j = 0; (j < i - 1); j++) - ret.push_back(char_array_3[j]); + for (j = 0; j < i - 1; j++) { + if (out < buf_len) { + buf[out++] = char_array_3[j]; + } else { + truncated = true; + } + } } + if (truncated) { + ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); + } + + return out; +} + +std::vector base64_decode(const std::string &encoded_string) { + // Calculate maximum decoded size: every 4 base64 chars = 3 bytes + size_t max_len = ((encoded_string.size() + 3) / 4) * 3; + std::vector ret(max_len); + size_t actual_len = base64_decode(encoded_string, ret.data(), max_len); + ret.resize(actual_len); return ret; } From c124d72ea934e01b7b417993f6fbd957f0a7025e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:45:27 +0100 Subject: [PATCH 11/57] [esp8266] Eliminate up to 16ms socket latency (#12397) --- .../components/socket/lwip_raw_tcp_impl.cpp | 49 ++++++++++++++++--- esphome/components/socket/socket.h | 9 ++++ esphome/core/application.cpp | 7 +++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index e57af91b77..5538206058 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -14,13 +14,36 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_ESP8266 +#include // For esp_schedule() +#endif + namespace esphome { namespace socket { +#ifdef USE_ESP8266 +// Flag to signal socket activity - checked by socket_delay() to exit early +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_socket_woke = false; + +void socket_delay(uint32_t ms) { + // Use esp_delay with a callback that checks if socket data arrived. + // This allows the delay to exit early when socket_wake() is called by + // lwip recv_fn/accept_fn callbacks, reducing socket latency. + s_socket_woke = false; + esp_delay(ms, []() { return !s_socket_woke; }); +} + +void socket_wake() { + s_socket_woke = true; + esp_schedule(); +} +#endif + static const char *const TAG = "socket.lwip"; // set to 1 to enable verbose lwip logging -#if 0 +#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) #else #define LWIP_LOG(msg, ...) @@ -323,9 +346,10 @@ class LWIPRawImpl : public Socket { for (int i = 0; i < iovcnt; i++) { ssize_t err = read(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { - if (ret != 0) + if (ret != 0) { // if we already read some don't return an error break; + } return err; } ret += err; @@ -393,9 +417,10 @@ class LWIPRawImpl : public Socket { ssize_t written = internal_write(buf, len); if (written == -1) return -1; - if (written == 0) + if (written == 0) { // no need to output if nothing written return 0; + } if (nodelay_) { int err = internal_output(); if (err == -1) @@ -408,18 +433,20 @@ class LWIPRawImpl : public Socket { for (int i = 0; i < iovcnt; i++) { ssize_t err = internal_write(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { - if (written != 0) + if (written != 0) { // if we already read some don't return an error break; + } return err; } written += err; if ((size_t) err != iov[i].iov_len) break; } - if (written == 0) + if (written == 0) { // no need to output if nothing written return 0; + } if (nodelay_) { int err = internal_output(); if (err == -1) @@ -473,6 +500,10 @@ class LWIPRawImpl : public Socket { } else { pbuf_cat(rx_buf_, pb); } +#ifdef USE_ESP8266 + // Wake the main loop immediately so it can process the received data. + socket_wake(); +#endif return ERR_OK; } @@ -612,7 +643,7 @@ class LWIPRawListenImpl : public LWIPRawImpl { } private: - err_t accept_fn(struct tcp_pcb *newpcb, err_t err) { + err_t accept_fn_(struct tcp_pcb *newpcb, err_t err) { LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err); if (err != ERR_OK || newpcb == nullptr) { // "An error code if there has been an error accepting. Only return ERR_ABRT if you have @@ -633,12 +664,16 @@ class LWIPRawListenImpl : public LWIPRawImpl { sock->init(); accepted_sockets_[accepted_socket_count_++] = std::move(sock); LWIP_LOG("Accepted connection, queue size: %d", accepted_socket_count_); +#ifdef USE_ESP8266 + // Wake the main loop immediately so it can accept the new connection. + socket_wake(); +#endif return ERR_OK; } static err_t s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err) { LWIPRawListenImpl *arg_this = reinterpret_cast(arg); - return arg_this->accept_fn(newpcb, err); + return arg_this->accept_fn_(newpcb, err); } // Accept queue - holds incoming connections briefly until the event loop calls accept() diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 78a89fe008..8936b2cd10 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -82,6 +82,15 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::stri /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +#if defined(USE_ESP8266) && 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. +void socket_delay(uint32_t ms); + +/// Called by lwip callbacks to signal socket activity and wake delay. +void socket_wake(); +#endif + } // namespace socket } // namespace esphome #endif diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 75814ae253..a85d671a07 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -12,6 +12,10 @@ #include "esphome/components/status_led/status_led.h" #endif +#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +#include "esphome/components/socket/socket.h" +#endif + #ifdef USE_SOCKET_SELECT_SUPPORT #include @@ -627,6 +631,9 @@ void Application::yield_with_select_(uint32_t delay_ms) { // No sockets registered, 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() + socket::socket_delay(delay_ms); #else // No select support, use regular delay delay(delay_ms); From d1d376ebc88186532b09afa20be1440415279ffc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:05:01 +0100 Subject: [PATCH 12/57] Bump actions/create-github-app-token from 2.2.0 to 2.2.1 (#12370) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/release.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 39164fc2ea..8e96297cc0 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -26,7 +26,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51aa1f885e..9933f63a1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,7 +221,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -256,7 +256,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -287,7 +287,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} From 7a9fce90cb1411a268701cc57b03d811db4269d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 18:13:40 +0100 Subject: [PATCH 13/57] [text] Add integration tests for text command API (#12401) --- tests/integration/fixtures/text_command.yaml | 37 ++++++ tests/integration/test_text_command.py | 126 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 tests/integration/fixtures/text_command.yaml create mode 100644 tests/integration/test_text_command.py diff --git a/tests/integration/fixtures/text_command.yaml b/tests/integration/fixtures/text_command.yaml new file mode 100644 index 0000000000..cc91e2f792 --- /dev/null +++ b/tests/integration/fixtures/text_command.yaml @@ -0,0 +1,37 @@ +esphome: + name: host-text-command-test + +host: + +api: + batch_delay: 0ms + +logger: + +text: + - platform: template + name: "Test Text" + id: test_text + optimistic: true + min_length: 0 + max_length: 255 + mode: text + initial_value: "initial" + + - platform: template + name: "Test Password" + id: test_password + optimistic: true + min_length: 4 + max_length: 32 + mode: password + initial_value: "secret" + + - platform: template + name: "Test Text Long" + id: test_text_long + optimistic: true + min_length: 0 + max_length: 255 + mode: text + initial_value: "" diff --git a/tests/integration/test_text_command.py b/tests/integration/test_text_command.py new file mode 100644 index 0000000000..82fe981578 --- /dev/null +++ b/tests/integration/test_text_command.py @@ -0,0 +1,126 @@ +"""Integration test for text command zero-copy optimization. + +Tests that TextCommandRequest correctly handles the pointer_to_buffer +optimization for the state field, ensuring text values are properly +transmitted via the API. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_text_command( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test text command with various string values including edge cases.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "host-text-command-test" + + # Get list of entities + entities, _ = await client.list_entities_services() + + # Find our text entities using require_entity + test_text = require_entity(entities, "test_text", TextInfo, "Test Text entity") + test_password = require_entity( + entities, "test_password", TextInfo, "Test Password entity" + ) + test_text_long = require_entity( + entities, "test_text_long", TextInfo, "Test Text Long entity" + ) + + # Track state changes + states: dict[int, Any] = {} + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + states[state.key] = state + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + # Set up InitialStateHelper to swallow initial state broadcasts + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for all initial states to be received + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Verify initial states were received + assert test_text.key in initial_state_helper.initial_states + initial_text_state = initial_state_helper.initial_states[test_text.key] + assert isinstance(initial_text_state, TextState) + assert initial_text_state.state == "initial" + + async def wait_for_state_change(key: int, timeout: float = 2.0) -> Any: + """Wait for a state change for the given entity key.""" + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + # Test 1: Simple text value + client.text_command(key=test_text.key, state="hello world") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello world" + + # Test 2: Empty string (edge case for zero-copy) + client.text_command(key=test_text.key, state="") + state = await wait_for_state_change(test_text.key) + assert state.state == "" + + # Test 3: Single character + client.text_command(key=test_text.key, state="x") + state = await wait_for_state_change(test_text.key) + assert state.state == "x" + + # Test 4: String with special characters + client.text_command(key=test_text.key, state="hello\tworld\n!") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello\tworld\n!" + + # Test 5: Unicode characters + client.text_command(key=test_text.key, state="hello δΈ–η•Œ 🌍") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello δΈ–η•Œ 🌍" + + # Test 6: Long string (tests buffer handling) + long_text = "a" * 200 + client.text_command(key=test_text_long.key, state=long_text) + state = await wait_for_state_change(test_text_long.key) + assert state.state == long_text + assert len(state.state) == 200 + + # Test 7: Password field (same mechanism, different mode) + client.text_command(key=test_password.key, state="newpassword123") + state = await wait_for_state_change(test_password.key) + assert state.state == "newpassword123" + + # Test 8: String with null bytes embedded (edge case) + # Note: protobuf strings should handle this but it's good to verify + client.text_command(key=test_text.key, state="before\x00after") + state = await wait_for_state_change(test_text.key) + assert state.state == "before\x00after" + + # Test 9: Rapid successive commands (tests buffer reuse) + for i in range(5): + client.text_command(key=test_text.key, state=f"rapid_{i}") + state = await wait_for_state_change(test_text.key) + assert state.state == f"rapid_{i}" From 22918d3bd5d96793dce4ad208262b81ca71ea709 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:21:29 +0100 Subject: [PATCH 14/57] Bump peter-evans/create-pull-request from 7.0.11 to 8.0.0 (#12409) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/sync-device-classes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 2c3219e38e..8c830d99c7 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -41,7 +41,7 @@ jobs: python script/run-in-env.py pre-commit run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot From 1f0a27b18181cd13d8979b9ca88a21510c1844f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:34:24 +0100 Subject: [PATCH 15/57] Bump codecov/codecov-action from 5.5.1 to 5.5.2 (#12408) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03eadb5f0a..c067df237f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,7 +152,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From 369cc70fdfa26b803ec24432952ddf2c0e5e571f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Dec 2025 02:10:42 +0100 Subject: [PATCH 16/57] [climate] Save 48 bytes per entity by conditionally compiling visual overrides (#12406) --- esphome/components/climate/__init__.py | 5 +++++ esphome/components/climate/climate.cpp | 27 ++++++++++++++------------ esphome/components/climate/climate.h | 16 +++++++++------ esphome/core/defines.h | 1 + 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 5824e68141..b8e49db6c0 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -275,10 +275,13 @@ async def setup_climate_core_(var, config): visual = config[CONF_VISUAL] if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_min_temperature_override(min_temp)) if (max_temp := visual.get(CONF_MAX_TEMPERATURE)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_max_temperature_override(max_temp)) if (temp_step := visual.get(CONF_TEMPERATURE_STEP)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add( var.set_visual_temperature_step_override( temp_step[CONF_TARGET_TEMPERATURE], @@ -286,8 +289,10 @@ async def setup_climate_core_(var, config): ) ) if (min_humidity := visual.get(CONF_MIN_HUMIDITY)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_min_humidity_override(min_humidity)) if (max_humidity := visual.get(CONF_MAX_HUMIDITY)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_max_humidity_override(max_humidity)) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index b0fba6aa62..3bc20a17c6 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -473,26 +473,28 @@ void Climate::publish_state() { ClimateTraits Climate::get_traits() { auto traits = this->traits(); - if (this->visual_min_temperature_override_.has_value()) { - traits.set_visual_min_temperature(*this->visual_min_temperature_override_); +#ifdef USE_CLIMATE_VISUAL_OVERRIDES + if (!std::isnan(this->visual_min_temperature_override_)) { + traits.set_visual_min_temperature(this->visual_min_temperature_override_); } - if (this->visual_max_temperature_override_.has_value()) { - traits.set_visual_max_temperature(*this->visual_max_temperature_override_); + if (!std::isnan(this->visual_max_temperature_override_)) { + traits.set_visual_max_temperature(this->visual_max_temperature_override_); } - if (this->visual_target_temperature_step_override_.has_value()) { - traits.set_visual_target_temperature_step(*this->visual_target_temperature_step_override_); - traits.set_visual_current_temperature_step(*this->visual_current_temperature_step_override_); + if (!std::isnan(this->visual_target_temperature_step_override_)) { + traits.set_visual_target_temperature_step(this->visual_target_temperature_step_override_); + traits.set_visual_current_temperature_step(this->visual_current_temperature_step_override_); } - if (this->visual_min_humidity_override_.has_value()) { - traits.set_visual_min_humidity(*this->visual_min_humidity_override_); + if (!std::isnan(this->visual_min_humidity_override_)) { + traits.set_visual_min_humidity(this->visual_min_humidity_override_); } - if (this->visual_max_humidity_override_.has_value()) { - traits.set_visual_max_humidity(*this->visual_max_humidity_override_); + if (!std::isnan(this->visual_max_humidity_override_)) { + traits.set_visual_max_humidity(this->visual_max_humidity_override_); } - +#endif return traits; } +#ifdef USE_CLIMATE_VISUAL_OVERRIDES void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) { this->visual_min_temperature_override_ = visual_min_temperature_override; } @@ -513,6 +515,7 @@ void Climate::set_visual_min_humidity_override(float visual_min_humidity_overrid void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) { this->visual_max_humidity_override_ = visual_max_humidity_override; } +#endif ClimateCall Climate::make_call() { return ClimateCall(this); } diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 28a73d8c05..82df4b815f 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -213,11 +213,13 @@ class Climate : public EntityBase { */ ClimateTraits get_traits(); +#ifdef USE_CLIMATE_VISUAL_OVERRIDES void set_visual_min_temperature_override(float visual_min_temperature_override); void set_visual_max_temperature_override(float visual_max_temperature_override); void set_visual_temperature_step_override(float target, float current); void set_visual_min_humidity_override(float visual_min_humidity_override); void set_visual_max_humidity_override(float visual_max_humidity_override); +#endif /// Check if a custom fan mode is currently active. bool has_custom_fan_mode() const { return this->custom_fan_mode_ != nullptr; } @@ -321,12 +323,14 @@ class Climate : public EntityBase { CallbackManager state_callback_{}; CallbackManager control_callback_{}; ESPPreferenceObject rtc_; - optional visual_min_temperature_override_{}; - optional visual_max_temperature_override_{}; - optional visual_target_temperature_step_override_{}; - optional visual_current_temperature_step_override_{}; - optional visual_min_humidity_override_{}; - optional visual_max_humidity_override_{}; +#ifdef USE_CLIMATE_VISUAL_OVERRIDES + float visual_min_temperature_override_{NAN}; + float visual_max_temperature_override_{NAN}; + float visual_target_temperature_step_override_{NAN}; + float visual_current_temperature_step_override_{NAN}; + float visual_min_humidity_override_{NAN}; + float visual_max_humidity_override_{NAN}; +#endif private: /** The active custom fan mode (private - enforces use of safe setters). diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a5170d73ff..750cab5bba 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -28,6 +28,7 @@ #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE +#define USE_CLIMATE_VISUAL_OVERRIDES #define USE_CONTROLLER_REGISTRY #define USE_COVER #define USE_DATETIME From 74218bc74271b4b282514b1fae1b8547cdbaef6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 19:33:22 -0600 Subject: [PATCH 17/57] [api] Release prologue memory after noise handshake completes (#12412) --- esphome/components/api/api_frame_helper_noise.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index ae69f0b673..1d6f32ee9d 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -539,7 +539,8 @@ APIError APINoiseFrameHelper::init_handshake_() { if (aerr != APIError::OK) return aerr; // set_prologue copies it into handshakestate, so we can get rid of it now - prologue_ = {}; + // Use swap idiom to actually release memory (= {} only clears size, not capacity) + std::vector().swap(prologue_); err = noise_handshakestate_start(handshake_); aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED); From 8d1e68c4c17e572856419f91d49914358f9a3023 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:53:12 -0600 Subject: [PATCH 18/57] Bump tornado from 6.5.2 to 6.5.3 (#12430) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 71aaf47ddb..7a50e1296f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.2 +tornado==6.5.3 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 From d30d8156c1065897748c66a431df19d6102c343b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:31:17 -0500 Subject: [PATCH 19/57] [http_request] Skip update check when network not connected (#12418) Co-authored-by: Claude --- .../components/http_request/update/http_request_update.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index c91b0eba73..26af754e69 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -36,6 +36,10 @@ void HttpRequestUpdate::setup() { } void HttpRequestUpdate::update() { + if (!network::is_connected()) { + ESP_LOGD(TAG, "Network not connected, skipping update check"); + return; + } #ifdef USE_ESP32 xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_); #else From 1d13d18a165fcefcae203643db9a35e5c25c460c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:19:29 +0100 Subject: [PATCH 20/57] [light] Add zero-copy support for API effect commands (#12384) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_pb2.cpp | 7 +++++-- esphome/components/api/api_pb2.h | 5 +++-- esphome/components/api/api_pb2_dump.cpp | 4 +++- esphome/components/light/light_call.cpp | 9 +++++---- esphome/components/light/light_call.h | 4 +++- 7 files changed, 21 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 2534ad0b1f..50af5061c0 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -579,7 +579,7 @@ message LightCommandRequest { bool has_flash_length = 16; uint32 flash_length = 17; bool has_effect = 18; - string effect = 19; + string effect = 19 [(pointer_to_buffer) = true]; uint32 device_id = 28 [(field_ifdef) = "USE_DEVICES"]; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4b10610281..09b311c1e4 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -533,7 +533,7 @@ void APIConnection::light_command(const LightCommandRequest &msg) { if (msg.has_flash_length) call.set_flash_length(msg.flash_length); if (msg.has_effect) - call.set_effect(msg.effect); + call.set_effect(reinterpret_cast(msg.effect), msg.effect_len); call.perform(); } #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 128f82fe7f..4a89ee78e1 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -611,9 +611,12 @@ bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 19: - this->effect = value.as_string(); + case 19: { + // Use raw data directly to avoid allocation + this->effect = value.data(); + this->effect_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 49f1ea3c52..f23a62fc3c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -840,7 +840,7 @@ class LightStateResponse final : public StateResponseProtoMessage { class LightCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 32; - static constexpr uint8_t ESTIMATED_SIZE = 112; + static constexpr uint8_t ESTIMATED_SIZE = 122; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "light_command_request"; } #endif @@ -869,7 +869,8 @@ class LightCommandRequest final : public CommandProtoMessage { bool has_flash_length{false}; uint32_t flash_length{0}; bool has_effect{false}; - std::string effect{}; + const uint8_t *effect{nullptr}; + uint16_t effect_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ca69d1ff00..5e271f41cb 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -999,7 +999,9 @@ void LightCommandRequest::dump_to(std::string &out) const { dump_field(out, "has_flash_length", this->has_flash_length); dump_field(out, "flash_length", this->flash_length); dump_field(out, "has_effect", this->has_effect); - dump_field(out, "effect", this->effect); + out.append(" effect: "); + out.append(format_hex_pretty(this->effect, this->effect_len)); + out.append("\n"); #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index dca5861734..8161e8b814 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -504,8 +504,8 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() { #undef KEY } -LightCall &LightCall::set_effect(const std::string &effect) { - if (strcasecmp(effect.c_str(), "none") == 0) { +LightCall &LightCall::set_effect(const char *effect, size_t len) { + if (len == 4 && strncasecmp(effect, "none", 4) == 0) { this->set_effect(0); return *this; } @@ -513,15 +513,16 @@ LightCall &LightCall::set_effect(const std::string &effect) { bool found = false; for (uint32_t i = 0; i < this->parent_->effects_.size(); i++) { LightEffect *e = this->parent_->effects_[i]; + const char *name = e->get_name(); - if (strcasecmp(effect.c_str(), e->get_name()) == 0) { + if (strncasecmp(effect, name, len) == 0 && name[len] == '\0') { this->set_effect(i + 1); found = true; break; } } if (!found) { - ESP_LOGW(TAG, "'%s': no such effect '%s'", this->parent_->get_name().c_str(), effect.c_str()); + ESP_LOGW(TAG, "'%s': no such effect '%.*s'", this->parent_->get_name().c_str(), (int) len, effect); } return *this; } diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 6931b58b9d..0926ab6108 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -129,7 +129,9 @@ class LightCall { /// Set the effect of the light by its name. LightCall &set_effect(optional effect); /// Set the effect of the light by its name. - LightCall &set_effect(const std::string &effect); + LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); } + /// Set the effect of the light by its name and length (zero-copy from API). + LightCall &set_effect(const char *effect, size_t len); /// Set the effect of the light by its internal index number (only for internal use). LightCall &set_effect(uint32_t effect_number); LightCall &set_effect(optional effect_number); From 78b76045ce6fc3ebadb54740b75c23fa54678e2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:20:23 +0100 Subject: [PATCH 21/57] [api] Fix potential buffer overflow in noise PSK base64 decode (#12395) --- esphome/components/api/api_connection.cpp | 2 +- esphome/core/helpers.cpp | 44 +++++++++++++++-------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 09b311c1e4..5186e5afda 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1669,7 +1669,7 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption } else { ESP_LOGW(TAG, "Failed to clear encryption key"); } - } else if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) { + } else if (base64_decode(msg.key, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 77102c8db2..732e8b6f8b 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -480,22 +480,13 @@ std::string base64_encode(const uint8_t *buf, size_t buf_len) { } size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { - std::vector decoded = base64_decode(encoded_string); - if (decoded.size() > buf_len) { - ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); - decoded.resize(buf_len); - } - memcpy(buf, decoded.data(), decoded.size()); - return decoded.size(); -} - -std::vector base64_decode(const std::string &encoded_string) { int in_len = encoded_string.size(); int i = 0; int j = 0; int in = 0; + size_t out = 0; uint8_t char_array_4[4], char_array_3[3]; - std::vector ret; + bool truncated = false; // SAFETY: The loop condition checks is_base64() before processing each character. // This ensures base64_find_char() is only called on valid base64 characters, @@ -511,8 +502,13 @@ std::vector base64_decode(const std::string &encoded_string) { char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - for (i = 0; (i < 3); i++) - ret.push_back(char_array_3[i]); + for (i = 0; i < 3; i++) { + if (out < buf_len) { + buf[out++] = char_array_3[i]; + } else { + truncated = true; + } + } i = 0; } } @@ -528,10 +524,28 @@ std::vector base64_decode(const std::string &encoded_string) { char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - for (j = 0; (j < i - 1); j++) - ret.push_back(char_array_3[j]); + for (j = 0; j < i - 1; j++) { + if (out < buf_len) { + buf[out++] = char_array_3[j]; + } else { + truncated = true; + } + } } + if (truncated) { + ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); + } + + return out; +} + +std::vector base64_decode(const std::string &encoded_string) { + // Calculate maximum decoded size: every 4 base64 chars = 3 bytes + size_t max_len = ((encoded_string.size() + 3) / 4) * 3; + std::vector ret(max_len); + size_t actual_len = base64_decode(encoded_string, ret.data(), max_len); + ret.resize(actual_len); return ret; } From 5567d96dd961c0379e3f3d6f512e3310a8cc6abe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 04:45:27 +0100 Subject: [PATCH 22/57] [esp8266] Eliminate up to 16ms socket latency (#12397) --- .../components/socket/lwip_raw_tcp_impl.cpp | 49 ++++++++++++++++--- esphome/components/socket/socket.h | 9 ++++ esphome/core/application.cpp | 7 +++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index e57af91b77..5538206058 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -14,13 +14,36 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_ESP8266 +#include // For esp_schedule() +#endif + namespace esphome { namespace socket { +#ifdef USE_ESP8266 +// Flag to signal socket activity - checked by socket_delay() to exit early +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_socket_woke = false; + +void socket_delay(uint32_t ms) { + // Use esp_delay with a callback that checks if socket data arrived. + // This allows the delay to exit early when socket_wake() is called by + // lwip recv_fn/accept_fn callbacks, reducing socket latency. + s_socket_woke = false; + esp_delay(ms, []() { return !s_socket_woke; }); +} + +void socket_wake() { + s_socket_woke = true; + esp_schedule(); +} +#endif + static const char *const TAG = "socket.lwip"; // set to 1 to enable verbose lwip logging -#if 0 +#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) #else #define LWIP_LOG(msg, ...) @@ -323,9 +346,10 @@ class LWIPRawImpl : public Socket { for (int i = 0; i < iovcnt; i++) { ssize_t err = read(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { - if (ret != 0) + if (ret != 0) { // if we already read some don't return an error break; + } return err; } ret += err; @@ -393,9 +417,10 @@ class LWIPRawImpl : public Socket { ssize_t written = internal_write(buf, len); if (written == -1) return -1; - if (written == 0) + if (written == 0) { // no need to output if nothing written return 0; + } if (nodelay_) { int err = internal_output(); if (err == -1) @@ -408,18 +433,20 @@ class LWIPRawImpl : public Socket { for (int i = 0; i < iovcnt; i++) { ssize_t err = internal_write(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { - if (written != 0) + if (written != 0) { // if we already read some don't return an error break; + } return err; } written += err; if ((size_t) err != iov[i].iov_len) break; } - if (written == 0) + if (written == 0) { // no need to output if nothing written return 0; + } if (nodelay_) { int err = internal_output(); if (err == -1) @@ -473,6 +500,10 @@ class LWIPRawImpl : public Socket { } else { pbuf_cat(rx_buf_, pb); } +#ifdef USE_ESP8266 + // Wake the main loop immediately so it can process the received data. + socket_wake(); +#endif return ERR_OK; } @@ -612,7 +643,7 @@ class LWIPRawListenImpl : public LWIPRawImpl { } private: - err_t accept_fn(struct tcp_pcb *newpcb, err_t err) { + err_t accept_fn_(struct tcp_pcb *newpcb, err_t err) { LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err); if (err != ERR_OK || newpcb == nullptr) { // "An error code if there has been an error accepting. Only return ERR_ABRT if you have @@ -633,12 +664,16 @@ class LWIPRawListenImpl : public LWIPRawImpl { sock->init(); accepted_sockets_[accepted_socket_count_++] = std::move(sock); LWIP_LOG("Accepted connection, queue size: %d", accepted_socket_count_); +#ifdef USE_ESP8266 + // Wake the main loop immediately so it can accept the new connection. + socket_wake(); +#endif return ERR_OK; } static err_t s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err) { LWIPRawListenImpl *arg_this = reinterpret_cast(arg); - return arg_this->accept_fn(newpcb, err); + return arg_this->accept_fn_(newpcb, err); } // Accept queue - holds incoming connections briefly until the event loop calls accept() diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 78a89fe008..8936b2cd10 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -82,6 +82,15 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::stri /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +#if defined(USE_ESP8266) && 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. +void socket_delay(uint32_t ms); + +/// Called by lwip callbacks to signal socket activity and wake delay. +void socket_wake(); +#endif + } // namespace socket } // namespace esphome #endif diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 75814ae253..a85d671a07 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -12,6 +12,10 @@ #include "esphome/components/status_led/status_led.h" #endif +#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +#include "esphome/components/socket/socket.h" +#endif + #ifdef USE_SOCKET_SELECT_SUPPORT #include @@ -627,6 +631,9 @@ void Application::yield_with_select_(uint32_t delay_ms) { // No sockets registered, 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() + socket::socket_delay(delay_ms); #else // No select support, use regular delay delay(delay_ms); From 2c77668a058ae669dead37974e4bc48d7dc8fa99 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:31:17 -0500 Subject: [PATCH 23/57] [http_request] Skip update check when network not connected (#12418) Co-authored-by: Claude --- .../components/http_request/update/http_request_update.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index c91b0eba73..26af754e69 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -36,6 +36,10 @@ void HttpRequestUpdate::setup() { } void HttpRequestUpdate::update() { + if (!network::is_connected()) { + ESP_LOGD(TAG, "Network not connected, skipping update check"); + return; + } #ifdef USE_ESP32 xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_); #else From c9506b056d3157af85250dc3a8410679436f0d42 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:12:58 -0500 Subject: [PATCH 24/57] Bump version to 2025.12.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ecb156d1f3..75c624bf2b 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.12.0b1 +PROJECT_NUMBER = 2025.12.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 93dd39b982..61bdc7df8d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.12.0b1" +__version__ = "2025.12.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 26a08e3ae324653d351de3dfa333998ed0a23be4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:15:28 -0600 Subject: [PATCH 25/57] Bump actions/upload-artifact from 5.0.0 to 6.0.0 (#12452) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index a0c6568345..4c4bbf9981 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -62,7 +62,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c067df237f..141c9cc1d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -821,7 +821,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: memory-analysis-target path: memory-analysis-target.json @@ -885,7 +885,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: memory-analysis-pr path: memory-analysis-pr.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9933f63a1c..34f4416029 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,7 +138,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests From b3e967a2330cd4f5291e6fe3b6cefb7b5c02129d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:15:41 -0600 Subject: [PATCH 26/57] Bump actions/download-artifact from 6.0.0 to 7.0.0 (#12449) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 141c9cc1d6..aab565ca59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -915,13 +915,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34f4416029..10194aa599 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,7 +171,7 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download digests - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: pattern: digests-* path: /tmp/digests From 2b40af34594bbfe0c4d7ec7dd0e981f653855b72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:16:29 -0600 Subject: [PATCH 27/57] Bump actions/cache from 4.3.0 to 5.0.1 (#12450) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aab565ca59..434aa388f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: venv # yamllint disable-line rule:line-length @@ -157,7 +157,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -193,7 +193,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -223,7 +223,7 @@ jobs: echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -245,7 +245,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -334,14 +334,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -413,14 +413,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -502,14 +502,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -735,7 +735,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -759,7 +759,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -800,7 +800,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -847,7 +847,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} From 4993bb2f495c52f6173634452c1964c7ebab69b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:16:41 -0600 Subject: [PATCH 28/57] Bump github/codeql-action from 4.31.7 to 4.31.8 (#12451) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 481ad0ec34..f917ecd8f8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 with: category: "/language:${{matrix.language}}" From 9126b32c359f3770f23168bc5c55375d209d02ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:17:08 -0600 Subject: [PATCH 29/57] Bump actions/cache from 4.3.0 to 5.0.1 in /.github/actions/restore-python (#12453) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index c4ac3d1a9e..75586fd854 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: venv # yamllint disable-line rule:line-length From 51b187954a411378f3c151f3424a41e65b805960 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:20:06 +0000 Subject: [PATCH 30/57] Bump ruff from 0.14.8 to 0.14.9 (#12448) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49b87866f1..2f5076a6e6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.8 + rev: v0.14.9 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 60656712b9..bfb833e04d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.4 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.14.8 # also change in .pre-commit-config.yaml when updating +ruff==0.14.9 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 1a43a06dd4ecdd15b3507fd0c763704f4247b703 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 10:15:50 +0900 Subject: [PATCH 31/57] Add USE_SHA256 define to sha256 component to enable tests (#12457) --- esphome/components/sha256/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index f07157416d..5db0e77b76 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -12,6 +12,8 @@ CONFIG_SCHEMA = cv.Schema({}) async def to_code(config: ConfigType) -> None: + cg.add_define("USE_SHA256") + # Add OpenSSL library for host platform if not CORE.is_host: return From ff7651875e34a377df943ce2f11e950bf88aed5e Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 10:19:31 +0900 Subject: [PATCH 32/57] Add HMAC-MD5 component tests (#12459) --- esphome/components/hmac_md5/__init__.py | 4 +++ tests/components/hmac_md5/common.yaml | 34 +++++++++++++++++++ .../components/hmac_md5/test.bk72xx-ard.yaml | 1 + tests/components/hmac_md5/test.esp32-idf.yaml | 1 + .../components/hmac_md5/test.esp8266-ard.yaml | 1 + .../components/hmac_md5/test.rp2040-ard.yaml | 1 + 6 files changed, 42 insertions(+) create mode 100644 tests/components/hmac_md5/common.yaml create mode 100644 tests/components/hmac_md5/test.bk72xx-ard.yaml create mode 100644 tests/components/hmac_md5/test.esp32-idf.yaml create mode 100644 tests/components/hmac_md5/test.esp8266-ard.yaml create mode 100644 tests/components/hmac_md5/test.rp2040-ard.yaml diff --git a/esphome/components/hmac_md5/__init__.py b/esphome/components/hmac_md5/__init__.py index fe245c0cfd..e37eb9b116 100644 --- a/esphome/components/hmac_md5/__init__.py +++ b/esphome/components/hmac_md5/__init__.py @@ -1,2 +1,6 @@ +import esphome.config_validation as cv + AUTO_LOAD = ["md5"] CODEOWNERS = ["@dwmw2"] + +CONFIG_SCHEMA = cv.Schema({}) diff --git a/tests/components/hmac_md5/common.yaml b/tests/components/hmac_md5/common.yaml new file mode 100644 index 0000000000..ac6d7ecbaa --- /dev/null +++ b/tests/components/hmac_md5/common.yaml @@ -0,0 +1,34 @@ +esphome: + on_boot: + - lambda: |- + // Test HMAC-MD5 functionality + #ifdef USE_MD5 + using esphome::hmac_md5::HmacMD5; + HmacMD5 hmac; + + // Test with key "key" and message "The quick brown fox jumps over the lazy dog" + const char* key = "key"; + const char* message = "The quick brown fox jumps over the lazy dog"; + + hmac.init(key, strlen(key)); + hmac.add(message, strlen(message)); + hmac.calculate(); + + char hex_output[33]; + hmac.get_hex(hex_output); + hex_output[32] = '\0'; + + ESP_LOGD("HMAC_MD5", "HMAC-MD5('%s', '%s') = %s", key, message, hex_output); + + // Expected: 80070713463e7749b90c2dc24911e275 + const char* expected = "80070713463e7749b90c2dc24911e275"; + if (strcmp(hex_output, expected) == 0) { + ESP_LOGI("HMAC_MD5", "Test PASSED"); + } else { + ESP_LOGE("HMAC_MD5", "Test FAILED. Expected %s", expected); + } + #else + ESP_LOGW("HMAC_MD5", "HMAC-MD5 not available on this platform"); + #endif + +hmac_md5: diff --git a/tests/components/hmac_md5/test.bk72xx-ard.yaml b/tests/components/hmac_md5/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_md5/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_md5/test.esp32-idf.yaml b/tests/components/hmac_md5/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_md5/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_md5/test.esp8266-ard.yaml b/tests/components/hmac_md5/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_md5/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_md5/test.rp2040-ard.yaml b/tests/components/hmac_md5/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_md5/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 6fce0a6104513dfafd90e32082bfe38903a3bb9d Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 11:50:34 +0900 Subject: [PATCH 33/57] Add host platform support to MD5 component (#12458) --- esphome/components/md5/__init__.py | 10 +++++++ esphome/components/md5/md5.cpp | 38 ++++++++++++++++++++++++ esphome/components/md5/md5.h | 11 ++++++- tests/components/hmac_md5/test.host.yaml | 1 + 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tests/components/hmac_md5/test.host.yaml diff --git a/esphome/components/md5/__init__.py b/esphome/components/md5/__init__.py index 1af9ee0b29..1710b00e66 100644 --- a/esphome/components/md5/__init__.py +++ b/esphome/components/md5/__init__.py @@ -1,7 +1,17 @@ import esphome.codegen as cg +from esphome.core import CORE +from esphome.helpers import IS_MACOS CODEOWNERS = ["@esphome/core"] async def to_code(config): cg.add_define("USE_MD5") + + # Add OpenSSL library for host platform + if CORE.is_host: + if IS_MACOS: + # macOS needs special handling for Homebrew OpenSSL + cg.add_build_flag("-I/opt/homebrew/opt/openssl/include") + cg.add_build_flag("-L/opt/homebrew/opt/openssl/lib") + cg.add_build_flag("-lcrypto") diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 866f00eda4..26554e4d3c 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -39,6 +39,44 @@ void MD5Digest::add(const uint8_t *data, size_t len) { br_md5_update(&this->ctx_ void MD5Digest::calculate() { br_md5_out(&this->ctx_, this->digest_); } #endif // USE_RP2040 +#ifdef USE_HOST +MD5Digest::~MD5Digest() { + if (this->ctx_) { + EVP_MD_CTX_free(this->ctx_); + } +} + +void MD5Digest::init() { + if (this->ctx_) { + EVP_MD_CTX_free(this->ctx_); + } + this->ctx_ = EVP_MD_CTX_new(); + EVP_DigestInit_ex(this->ctx_, EVP_md5(), nullptr); + this->calculated_ = false; + memset(this->digest_, 0, 16); +} + +void MD5Digest::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + EVP_DigestUpdate(this->ctx_, data, len); +} + +void MD5Digest::calculate() { + if (!this->ctx_) { + this->init(); + } + if (!this->calculated_) { + unsigned int len = 16; + EVP_DigestFinal_ex(this->ctx_, this->digest_, &len); + this->calculated_ = true; + } +} +#else +MD5Digest::~MD5Digest() = default; +#endif // USE_HOST + } // namespace md5 } // namespace esphome #endif diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index b0da2c0a3b..6ff651b02e 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -5,6 +5,10 @@ #include "esphome/core/hash_base.h" +#ifdef USE_HOST +#include +#endif + #ifdef USE_ESP32 #include "esp_rom_md5.h" #define MD5_CTX_TYPE md5_context_t @@ -31,7 +35,7 @@ namespace md5 { class MD5Digest : public HashBase { public: MD5Digest() = default; - ~MD5Digest() override = default; + ~MD5Digest() override; /// Initialize a new MD5 digest computation. void init() override; @@ -47,7 +51,12 @@ class MD5Digest : public HashBase { size_t get_size() const override { return 16; } protected: +#ifdef USE_HOST + EVP_MD_CTX *ctx_{nullptr}; + bool calculated_{false}; +#else MD5_CTX_TYPE ctx_{}; +#endif }; } // namespace md5 diff --git a/tests/components/hmac_md5/test.host.yaml b/tests/components/hmac_md5/test.host.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_md5/test.host.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From e0ce66e01119c5c89e9a4f8cf82b2dbd6d1793c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 07:38:31 -0600 Subject: [PATCH 34/57] [core] Fix CORE.raw_config not updated after package merge (#12456) --- esphome/config.py | 4 +-- .../component_tests/packages/test_packages.py | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 694716be34..6f6ad4886b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1010,14 +1010,14 @@ def validate_config( result.add_error(err) return result - CORE.raw_config = config - # 1.1. Merge packages if CONF_PACKAGES in config: from esphome.components.packages import merge_packages config = merge_packages(config) + CORE.raw_config = config + # 1.2. Resolve !extend and !remove and check for REPLACEME # After this step, there will not be any Extend or Remove values in the config anymore try: diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 3829e540d7..22fb2c4e32 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages +import esphome.config as config_module from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv @@ -33,6 +34,7 @@ from esphome.const import ( CONF_VARS, CONF_WIFI, ) +from esphome.core import CORE from esphome.util import OrderedDict # Test strings @@ -991,3 +993,35 @@ def test_package_merge_invalid(invalid_package) -> None: with pytest.raises(cv.Invalid): merge_packages(config) + + +def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: + """Test that CORE.raw_config contains esphome section from merged package. + + This is a regression test for the bug where CORE.raw_config was set before + packages were merged, causing KeyError when components accessed + CORE.raw_config[CONF_ESPHOME] and the esphome section came from a package. + """ + # Create a config where esphome section comes from a package + test_config = OrderedDict() + test_config[CONF_PACKAGES] = { + "base": { + CONF_ESPHOME: {CONF_NAME: TEST_DEVICE_NAME}, + } + } + test_config["esp32"] = {"board": "esp32dev"} + + # Set up CORE for the test + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("# test config") + CORE.reset() + CORE.config_path = test_yaml + + # Call validate_config - this should merge packages and set CORE.raw_config + config_module.validate_config(test_config, {}) + + # Verify that CORE.raw_config contains the esphome section from the package + assert CONF_ESPHOME in CORE.raw_config, ( + "CORE.raw_config should contain esphome section after package merge" + ) + assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME From ede64a9f47f182e6ed4aa83d1c219e2319c21c79 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:47:15 +1000 Subject: [PATCH 35/57] [packet_transport] Ensure retransmission at update intervals (#12472) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../packet_transport/espnow_transport.cpp | 11 +++-------- .../espnow/packet_transport/espnow_transport.h | 1 - esphome/components/packet_transport/__init__.py | 5 +++++ .../packet_transport/packet_transport.cpp | 6 +++++- .../packet_transport/packet_transport.h | 3 ++- .../packet_transport/sx126x_transport.cpp | 6 ------ .../sx126x/packet_transport/sx126x_transport.h | 1 - .../packet_transport/sx127x_transport.cpp | 6 ------ .../sx127x/packet_transport/sx127x_transport.h | 1 - .../uart/packet_transport/uart_transport.cpp | 6 ------ .../uart/packet_transport/uart_transport.h | 1 - .../udp/packet_transport/udp_transport.cpp | 17 +---------------- .../udp/packet_transport/udp_transport.h | 2 -- tests/components/espnow/common.yaml | 4 ++-- 14 files changed, 18 insertions(+), 52 deletions(-) diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index d30e9447a0..c1252acc9d 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -13,7 +13,7 @@ static const char *const TAG = "espnow.transport"; bool ESPNowTransport::should_send() { return this->parent_ != nullptr && !this->parent_->is_failed(); } void ESPNowTransport::setup() { - packet_transport::PacketTransport::setup(); + PacketTransport::setup(); if (this->parent_ == nullptr) { ESP_LOGE(TAG, "ESPNow component not set"); @@ -26,15 +26,10 @@ void ESPNowTransport::setup() { this->peer_address_[2], this->peer_address_[3], this->peer_address_[4], this->peer_address_[5]); // Register received handler - this->parent_->register_received_handler(static_cast(this)); + this->parent_->register_received_handler(this); // Register broadcasted handler - this->parent_->register_broadcasted_handler(static_cast(this)); -} - -void ESPNowTransport::update() { - packet_transport::PacketTransport::update(); - this->updated_ = true; + this->parent_->register_broadcasted_handler(this); } void ESPNowTransport::send_packet(const std::vector &buf) const { diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 3629fad2cd..d85119db7d 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -18,7 +18,6 @@ class ESPNowTransport : public packet_transport::PacketTransport, public ESPNowBroadcastedHandler { public: void setup() override; - void update() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void set_peer_address(peer_address_t address) { diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 43da7740fe..1930e45e85 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -176,17 +176,22 @@ async def register_packet_transport(var, config): if encryption := provider.get(CONF_ENCRYPTION): cg.add(var.set_provider_encryption(name, hash_encryption_key(encryption))) + is_provider = False for sens_conf in config.get(CONF_SENSORS, ()): + is_provider = True sens_id = sens_conf[CONF_ID] sensor = await cg.get_variable(sens_id) bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id) cg.add(var.add_sensor(bcst_id, sensor)) for sens_conf in config.get(CONF_BINARY_SENSORS, ()): + is_provider = True sens_id = sens_conf[CONF_ID] sensor = await cg.get_variable(sens_id) bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id) cg.add(var.add_binary_sensor(bcst_id, sensor)) + if is_provider: + cg.add(var.set_is_provider(True)) if encryption := config.get(CONF_ENCRYPTION): cg.add(var.set_encryption_key(hash_encryption_key(encryption))) return providers diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 37e5f3d9e1..da7f5f8bff 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -263,6 +263,7 @@ void PacketTransport::flush_() { xxtea::encrypt((uint32_t *) (encode_buffer.data() + header_len), len / 4, (uint32_t *) this->encryption_key_.data()); } + ESP_LOGVV(TAG, "Sending packet %s", format_hex_pretty(encode_buffer.data(), encode_buffer.size()).c_str()); this->send_packet(encode_buffer); } @@ -316,6 +317,9 @@ void PacketTransport::send_data_(bool all) { } void PacketTransport::update() { + // resend all sensors if required + if (this->is_provider_) + this->send_data_(true); if (!this->ping_pong_enable_) { return; } @@ -551,7 +555,7 @@ void PacketTransport::loop() { if (this->resend_ping_key_) this->send_ping_pong_request_(); if (this->updated_) { - this->send_data_(this->resend_data_); + this->send_data_(false); } } diff --git a/esphome/components/packet_transport/packet_transport.h b/esphome/components/packet_transport/packet_transport.h index a2370e9749..86ec564fce 100644 --- a/esphome/components/packet_transport/packet_transport.h +++ b/esphome/components/packet_transport/packet_transport.h @@ -91,6 +91,7 @@ class PacketTransport : public PollingComponent { } } + void set_is_provider(bool is_provider) { this->is_provider_ = is_provider; } void set_encryption_key(std::vector key) { this->encryption_key_ = std::move(key); } void set_rolling_code_enable(bool enable) { this->rolling_code_enable_ = enable; } void set_ping_pong_enable(bool enable) { this->ping_pong_enable_ = enable; } @@ -129,7 +130,7 @@ class PacketTransport : public PollingComponent { uint32_t ping_pong_recyle_time_{}; uint32_t last_key_time_{}; bool resend_ping_key_{}; - bool resend_data_{}; + bool is_provider_{}; const char *name_{}; ESPPreferenceObject pref_{}; diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp index 2cfc4b700e..59d80bd297 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp @@ -12,12 +12,6 @@ void SX126xTransport::setup() { this->parent_->register_listener(this); } -void SX126xTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = true; -} - void SX126xTransport::send_packet(const std::vector &buf) const { this->parent_->transmit_packet(buf); } void SX126xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.h b/esphome/components/sx126x/packet_transport/sx126x_transport.h index 755d30417d..640c6a76f9 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.h +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.h @@ -11,7 +11,6 @@ namespace sx126x { class SX126xTransport : public packet_transport::PacketTransport, public Parented, public SX126xListener { public: void setup() override; - void update() override; void on_packet(const std::vector &packet, float rssi, float snr) override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp index b1d014bb96..893726e816 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp @@ -12,12 +12,6 @@ void SX127xTransport::setup() { this->parent_->register_listener(this); } -void SX127xTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = true; -} - void SX127xTransport::send_packet(const std::vector &buf) const { this->parent_->transmit_packet(buf); } void SX127xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.h b/esphome/components/sx127x/packet_transport/sx127x_transport.h index e27b7f8d57..6208372971 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.h +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.h @@ -11,7 +11,6 @@ namespace sx127x { class SX127xTransport : public packet_transport::PacketTransport, public Parented, public SX127xListener { public: void setup() override; - void update() override; void on_packet(const std::vector &packet, float rssi, float snr) override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/uart/packet_transport/uart_transport.cpp b/esphome/components/uart/packet_transport/uart_transport.cpp index 4a9aa0fe47..6b8eae611c 100644 --- a/esphome/components/uart/packet_transport/uart_transport.cpp +++ b/esphome/components/uart/packet_transport/uart_transport.cpp @@ -55,12 +55,6 @@ void UARTTransport::loop() { } } -void UARTTransport::update() { - this->updated_ = true; - this->resend_data_ = true; - PacketTransport::update(); -} - /** * Write a byte to the UART bus. If the byte is a flag or control byte, it will be escaped. * @param byte The byte to write. diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index e84bed95e6..1c92af536e 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -23,7 +23,6 @@ static const uint8_t CONTROL_BYTE = 0x7D; class UARTTransport : public packet_transport::PacketTransport, public UARTDevice { public: void loop() override; - void update() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } protected: diff --git a/esphome/components/udp/packet_transport/udp_transport.cpp b/esphome/components/udp/packet_transport/udp_transport.cpp index b92e0d64df..f3e33573a5 100644 --- a/esphome/components/udp/packet_transport/udp_transport.cpp +++ b/esphome/components/udp/packet_transport/udp_transport.cpp @@ -8,29 +8,14 @@ namespace udp { static const char *const TAG = "udp_transport"; -bool UDPTransport::should_send() { return this->should_broadcast_ && network::is_connected(); } +bool UDPTransport::should_send() { return network::is_connected(); } void UDPTransport::setup() { PacketTransport::setup(); - this->should_broadcast_ = this->ping_pong_enable_; -#ifdef USE_SENSOR - this->should_broadcast_ |= !this->sensors_.empty(); -#endif -#ifdef USE_BINARY_SENSOR - this->should_broadcast_ |= !this->binary_sensors_.empty(); -#endif - if (this->should_broadcast_) - this->parent_->set_should_broadcast(); if (!this->providers_.empty() || this->is_encrypted_()) { this->parent_->add_listener([this](std::vector &buf) { this->process_(buf); }); } } -void UDPTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = this->should_broadcast_; -} - void UDPTransport::send_packet(const std::vector &buf) const { this->parent_->send_packet(buf); } } // namespace udp } // namespace esphome diff --git a/esphome/components/udp/packet_transport/udp_transport.h b/esphome/components/udp/packet_transport/udp_transport.h index c87eb62780..8d01ae0909 100644 --- a/esphome/components/udp/packet_transport/udp_transport.h +++ b/esphome/components/udp/packet_transport/udp_transport.h @@ -12,14 +12,12 @@ namespace udp { class UDPTransport : public packet_transport::PacketTransport, public Parented { public: void setup() override; - void update() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } protected: void send_packet(const std::vector &buf) const override; bool should_send() override; - bool should_broadcast_{false}; size_t get_max_packet_size() override { return MAX_PACKET_SIZE; } }; diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 895ffb9d15..b724af54e0 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: sensors: - temp_sensor providers: - - name: test_provider + - name: test-provider encryption: key: "0123456789abcdef0123456789abcdef" @@ -71,6 +71,6 @@ sensor: id: temp_sensor - platform: packet_transport - provider: test_provider + provider: test-provider remote_id: temp_sensor id: remote_temp From 786d7266f519f0dfb7654fc9a8472dffe1a87687 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:47:52 -0500 Subject: [PATCH 36/57] [core] Fix polling_component_schema and type consistency (#12478) Co-authored-by: Claude --- esphome/config_validation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c52b791120..08fffa6cec 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -71,6 +71,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, + SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, VALID_SUBSTITUTIONS_CHARACTERS, @@ -894,7 +895,7 @@ def time_period_in_minutes_(value): def update_interval(value): if value == "never": - return 4294967295 # uint32_t max + return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) return positive_time_period_milliseconds(value) @@ -2009,7 +2010,7 @@ def polling_component_schema(default_update_interval): if default_update_interval is None: return COMPONENT_SCHEMA.extend( { - Required(CONF_UPDATE_INTERVAL): default_update_interval, + Required(CONF_UPDATE_INTERVAL): update_interval, } ) assert isinstance(default_update_interval, str) From cfc0d8bdfc1bba66d0b4b90cf3f4edb3859d96e5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Dec 2025 13:22:55 -0500 Subject: [PATCH 37/57] [cc1101] Add packet mode support (#12474) Co-authored-by: Claude --- esphome/components/cc1101/__init__.py | 105 +++++++++++- esphome/components/cc1101/cc1101.cpp | 171 +++++++++++++++++--- esphome/components/cc1101/cc1101.h | 49 +++++- esphome/components/cc1101/cc1101defs.h | 23 +++ esphome/components/const/__init__.py | 2 + esphome/components/sx126x/__init__.py | 3 +- esphome/components/sx127x/__init__.py | 3 +- tests/components/cc1101/common.yaml | 21 ++- tests/components/cc1101/test.esp32-idf.yaml | 2 +- tests/components/cc1101/test.esp8266.yaml | 2 +- 10 files changed, 340 insertions(+), 41 deletions(-) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index e6b31b84f8..1971817fb1 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -1,9 +1,17 @@ -from esphome import automation +from esphome import automation, pins from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_FREQUENCY, CONF_ID, CONF_WAIT_TIME +from esphome.const import ( + CONF_CHANNEL, + CONF_DATA, + CONF_FREQUENCY, + CONF_ID, + CONF_WAIT_TIME, +) +from esphome.core import ID CODEOWNERS = ["@lygris", "@gabest11"] DEPENDENCIES = ["spi"] @@ -29,7 +37,6 @@ CONF_MANCHESTER = "manchester" CONF_NUM_PREAMBLE = "num_preamble" CONF_SYNC1 = "sync1" CONF_SYNC0 = "sync0" -CONF_PKTLEN = "pktlen" CONF_MAGN_TARGET = "magn_target" CONF_MAX_LNA_GAIN = "max_lna_gain" CONF_MAX_DVGA_GAIN = "max_dvga_gain" @@ -41,6 +48,12 @@ CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook" CONF_FREEZE = "freeze" CONF_HYST_LEVEL = "hyst_level" +# Packet mode config keys +CONF_PACKET_MODE = "packet_mode" +CONF_PACKET_LENGTH = "packet_length" +CONF_WHITENING = "whitening" +CONF_GDO0_PIN = "gdo0_pin" + # Enums SyncMode = ns.enum("SyncMode", True) SYNC_MODE = { @@ -167,7 +180,6 @@ CONFIG_MAP = { CONF_NUM_PREAMBLE: cv.int_range(min=0, max=7), CONF_SYNC1: cv.hex_uint8_t, CONF_SYNC0: cv.hex_uint8_t, - CONF_PKTLEN: cv.uint8_t, CONF_MAGN_TARGET: cv.enum(MAGN_TARGET, upper=False), CONF_MAX_LNA_GAIN: cv.enum(MAX_LNA_GAIN, upper=False), CONF_MAX_DVGA_GAIN: cv.enum(MAX_DVGA_GAIN, upper=False), @@ -179,13 +191,36 @@ CONFIG_MAP = { CONF_FREEZE: cv.enum(FREEZE, upper=False), CONF_WAIT_TIME: cv.enum(WAIT_TIME, upper=False), CONF_HYST_LEVEL: cv.enum(HYST_LEVEL, upper=False), + CONF_PACKET_MODE: cv.boolean, + CONF_PACKET_LENGTH: cv.uint8_t, + CONF_CRC_ENABLE: cv.boolean, + CONF_WHITENING: cv.boolean, } -CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(CC1101Component)}) + +def _validate_packet_mode(config): + if config.get(CONF_PACKET_MODE, False): + if CONF_GDO0_PIN not in config: + raise cv.Invalid("gdo0_pin is required when packet_mode is enabled") + if CONF_PACKET_LENGTH not in config: + raise cv.Invalid("packet_length is required when packet_mode is enabled") + if config[CONF_PACKET_LENGTH] > 64: + raise cv.Invalid("packet_length must be <= 64 (FIFO size)") + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CC1101Component), + cv.Optional(CONF_GDO0_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), + } + ) .extend({cv.Optional(key): validator for key, validator in CONFIG_MAP.items()}) .extend(cv.COMPONENT_SCHEMA) - .extend(spi.spi_device_schema(cs_pin_required=True)) + .extend(spi.spi_device_schema(cs_pin_required=True)), + _validate_packet_mode, ) @@ -198,12 +233,29 @@ async def to_code(config): if key in config: cg.add(getattr(var, f"set_{key}")(config[key])) + if CONF_GDO0_PIN in config: + gdo0_pin = await cg.gpio_pin_expression(config[CONF_GDO0_PIN]) + cg.add(var.set_gdo0_pin(gdo0_pin)) + if CONF_ON_PACKET in config: + await automation.build_automation( + var.get_packet_trigger(), + [ + (cg.std_vector.template(cg.uint8), "x"), + (cg.float_, "rssi"), + (cg.uint8, "lqi"), + ], + config[CONF_ON_PACKET], + ) + # Actions BeginTxAction = ns.class_("BeginTxAction", automation.Action) BeginRxAction = ns.class_("BeginRxAction", automation.Action) ResetAction = ns.class_("ResetAction", automation.Action) SetIdleAction = ns.class_("SetIdleAction", automation.Action) +SendPacketAction = ns.class_( + "SendPacketAction", automation.Action, cg.Parented.template(CC1101Component) +) CC1101_ACTION_SCHEMA = cv.Schema( maybe_simple_id({cv.GenerateID(CONF_ID): cv.use_id(CC1101Component)}) @@ -218,3 +270,42 @@ async def cc1101_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var + + +def validate_raw_data(value): + if isinstance(value, str): + return value.encode("utf-8") + if isinstance(value, list): + return cv.Schema([cv.hex_uint8_t])(value) + raise cv.Invalid( + "data must either be a string wrapped in quotes or a list of bytes" + ) + + +SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(CC1101Component), + cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + }, + key=CONF_DATA, +) + + +@automation.register_action( + "cc1101.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA +) +async def send_packet_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + data = config[CONF_DATA] + if isinstance(data, bytes): + data = list(data) + if cg.is_template(data): + templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) + cg.add(var.set_data_template(templ)) + else: + # Generate static array in flash to avoid RAM copy + arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) + cg.add(var.set_data_static(arr, len(data))) + return var diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 3cbf09ded8..5b6eb545bc 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -143,6 +143,11 @@ void CC1101Component::setup() { return; } + // Setup GDO0 pin if configured + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->setup(); + } + this->initialized_ = true; for (uint8_t i = 0; i <= static_cast(Register::TEST0); i++) { @@ -151,8 +156,69 @@ void CC1101Component::setup() { } this->write_(static_cast(i)); } - this->write_(Register::PATABLE, this->pa_table_, sizeof(this->pa_table_)); + this->set_output_power(this->output_power_requested_); this->strobe_(Command::RX); + + // Defer pin mode setup until after all components have completed setup() + // This handles the case where remote_transmitter runs after CC1101 and changes pin mode + if (this->gdo0_pin_ != nullptr) { + this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); }); + } +} + +void CC1101Component::loop() { + if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr || + !this->gdo0_pin_->digital_read()) { + return; + } + + // Read state + this->read_(Register::RXBYTES); + uint8_t rx_bytes = this->state_.NUM_RXBYTES; + bool overflow = this->state_.RXFIFO_OVERFLOW; + if (overflow || rx_bytes == 0) { + ESP_LOGW(TAG, "RX FIFO overflow, flushing"); + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return; + } + + // Read packet + uint8_t payload_length; + if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { + this->read_(Register::FIFO, &payload_length, 1); + } else { + payload_length = this->state_.PKTLEN; + } + if (payload_length == 0 || payload_length > 64) { + ESP_LOGW(TAG, "Invalid payload length: %u", payload_length); + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return; + } + this->packet_.resize(payload_length); + this->read_(Register::FIFO, this->packet_.data(), payload_length); + + // Read status and trigger + uint8_t status[2]; + this->read_(Register::FIFO, status, 2); + int8_t rssi_raw = static_cast(status[0]); + float rssi = (rssi_raw * RSSI_STEP) - RSSI_OFFSET; + bool crc_ok = (status[1] & STATUS_CRC_OK_MASK) != 0; + uint8_t lqi = status[1] & STATUS_LQI_MASK; + if (this->state_.CRC_EN == 0 || crc_ok) { + this->packet_trigger_->trigger(this->packet_, rssi, lqi); + } + + // Return to rx + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); } void CC1101Component::dump_config() { @@ -177,9 +243,12 @@ void CC1101Component::dump_config() { } void CC1101Component::begin_tx() { - // Ensure Packet Format is 3 (Async Serial), use GDO0 as input during TX + // Ensure Packet Format is 3 (Async Serial) this->write_(Register::PKTCTRL0, 0x32); ESP_LOGV(TAG, "Beginning TX sequence"); + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); + } this->strobe_(Command::TX); if (!this->wait_for_state_(State::TX, 50)) { ESP_LOGW(TAG, "Timed out waiting for TX state!"); @@ -188,6 +257,9 @@ void CC1101Component::begin_tx() { void CC1101Component::begin_rx() { ESP_LOGV(TAG, "Beginning RX sequence"); + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); + } this->strobe_(Command::RX); } @@ -201,20 +273,6 @@ void CC1101Component::set_idle() { this->enter_idle_(); } -void CC1101Component::set_gdo0_config(uint8_t value) { - this->state_.GDO0_CFG = value; - if (this->initialized_) { - this->write_(Register::IOCFG0); - } -} - -void CC1101Component::set_gdo2_config(uint8_t value) { - this->state_.GDO2_CFG = value; - if (this->initialized_) { - this->write_(Register::IOCFG2); - } -} - bool CC1101Component::wait_for_state_(State target_state, uint32_t timeout_ms) { uint32_t start = millis(); while (millis() - start < timeout_ms) { @@ -282,6 +340,33 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) { this->disable(); } +CC1101Error CC1101Component::transmit_packet(const std::vector &packet) { + if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { + return CC1101Error::PARAMS; + } + + // Write packet + this->enter_idle_(); + this->strobe_(Command::FTX); + if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { + this->write_(Register::FIFO, static_cast(packet.size())); + } + this->write_(Register::FIFO, packet.data(), packet.size()); + this->strobe_(Command::TX); + if (!this->wait_for_state_(State::IDLE, 1000)) { + ESP_LOGW(TAG, "TX timeout"); + this->enter_idle_(); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return CC1101Error::TIMEOUT; + } + + // Return to rx + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return CC1101Error::NONE; +} + // Setters void CC1101Component::set_output_power(float value) { this->output_power_requested_ = value; @@ -428,6 +513,7 @@ void CC1101Component::set_modulation_type(Modulation value) { this->state_.PA_POWER = value == Modulation::MODULATION_ASK_OOK ? 1 : 0; if (this->initialized_) { this->enter_idle_(); + this->set_output_power(this->output_power_requested_); this->write_(Register::MDMCFG2); this->write_(Register::FREND0); this->strobe_(Command::RX); @@ -462,13 +548,6 @@ void CC1101Component::set_sync0(uint8_t value) { } } -void CC1101Component::set_pktlen(uint8_t value) { - this->state_.PKTLEN = value; - if (this->initialized_) { - this->write_(Register::PKTLEN); - } -} - void CC1101Component::set_magn_target(MagnTarget value) { this->state_.MAGN_TARGET = static_cast(value); if (this->initialized_) { @@ -546,4 +625,50 @@ void CC1101Component::set_hyst_level(HystLevel value) { } } +void CC1101Component::set_packet_mode(bool value) { + this->state_.PKT_FORMAT = + static_cast(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL); + if (value) { + // Configure GDO0 for FIFO status (asserts on RX FIFO threshold or end of packet) + this->state_.GDO0_CFG = 0x01; + // Set max RX FIFO threshold to ensure we only trigger on end-of-packet + this->state_.FIFO_THR = 15; + } else { + // Configure GDO0 for serial data (async serial mode) + this->state_.GDO0_CFG = 0x0D; + } + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + this->write_(Register::IOCFG0); + this->write_(Register::FIFOTHR); + } +} + +void CC1101Component::set_packet_length(uint8_t value) { + if (value == 0) { + this->state_.LENGTH_CONFIG = static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE); + } else { + this->state_.LENGTH_CONFIG = static_cast(LengthConfig::LENGTH_CONFIG_FIXED); + this->state_.PKTLEN = value; + } + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + this->write_(Register::PKTLEN); + } +} + +void CC1101Component::set_crc_enable(bool value) { + this->state_.CRC_EN = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + } +} + +void CC1101Component::set_whitening(bool value) { + this->state_.WHITE_DATA = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + } +} + } // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 65aeb2ea82..b896f7e974 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -5,9 +5,12 @@ #include "esphome/components/spi/spi.h" #include "esphome/core/automation.h" #include "cc1101defs.h" +#include namespace esphome::cc1101 { +enum class CC1101Error { NONE = 0, TIMEOUT, PARAMS, CRC_ERROR, FIFO_OVERFLOW }; + class CC1101Component : public Component, public spi::SPIDevice { @@ -15,6 +18,7 @@ class CC1101Component : public Component, CC1101Component(); void setup() override; + void loop() override; void dump_config() override; // Actions @@ -24,8 +28,7 @@ class CC1101Component : public Component, void set_idle(); // GDO Pin Configuration - void set_gdo0_config(uint8_t value); - void set_gdo2_config(uint8_t value); + void set_gdo0_pin(InternalGPIOPin *pin) { this->gdo0_pin_ = pin; } // Configuration Setters void set_output_power(float value); @@ -48,7 +51,6 @@ class CC1101Component : public Component, void set_num_preamble(uint8_t value); void set_sync1(uint8_t value); void set_sync0(uint8_t value); - void set_pktlen(uint8_t value); // AGC settings void set_magn_target(MagnTarget value); @@ -63,6 +65,16 @@ class CC1101Component : public Component, void set_wait_time(WaitTime value); void set_hyst_level(HystLevel value); + // Packet mode settings + void set_packet_mode(bool value); + void set_packet_length(uint8_t value); + void set_crc_enable(bool value); + void set_whitening(bool value); + + // Packet mode operations + CC1101Error transmit_packet(const std::vector &packet); + Trigger, float, uint8_t> *get_packet_trigger() const { return this->packet_trigger_; } + protected: uint16_t chip_id_{0}; bool initialized_{false}; @@ -73,6 +85,13 @@ class CC1101Component : public Component, CC1101State state_; + // GDO pin for packet reception + InternalGPIOPin *gdo0_pin_{nullptr}; + + // Packet handling + Trigger, float, uint8_t> *packet_trigger_{new Trigger, float, uint8_t>()}; + std::vector packet_; + // Low-level Helpers uint8_t strobe_(Command cmd); void write_(Register reg); @@ -107,4 +126,28 @@ template class SetIdleAction : public Action, public Pare void play(const Ts &...x) override { this->parent_->set_idle(); } }; +template class SendPacketAction : public Action, public Parented { + public: + void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } + void set_data_static(const uint8_t *data, size_t len) { + this->data_static_ = data; + this->data_static_len_ = len; + } + + void play(const Ts &...x) override { + if (this->data_func_) { + auto data = this->data_func_(x...); + this->parent_->transmit_packet(data); + } else if (this->data_static_ != nullptr) { + std::vector data(this->data_static_, this->data_static_ + this->data_static_len_); + this->parent_->transmit_packet(data); + } + } + + protected: + std::function(Ts...)> data_func_{}; + const uint8_t *data_static_{nullptr}; + size_t data_static_len_{0}; +}; + } // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h index afeb5f1d77..1bc42f5859 100644 --- a/esphome/components/cc1101/cc1101defs.h +++ b/esphome/components/cc1101/cc1101defs.h @@ -6,6 +6,12 @@ namespace esphome::cc1101 { static constexpr float XTAL_FREQUENCY = 26000000; +static constexpr float RSSI_OFFSET = 74.0f; +static constexpr float RSSI_STEP = 0.5f; + +static constexpr uint8_t STATUS_CRC_OK_MASK = 0x80; +static constexpr uint8_t STATUS_LQI_MASK = 0x7F; + static constexpr uint8_t BUS_BURST = 0x40; static constexpr uint8_t BUS_READ = 0x80; static constexpr uint8_t BUS_WRITE = 0x00; @@ -134,6 +140,10 @@ enum class SyncMode : uint8_t { SYNC_MODE_15_16, SYNC_MODE_16_16, SYNC_MODE_30_32, + SYNC_MODE_NONE_CS, + SYNC_MODE_15_16_CS, + SYNC_MODE_16_16_CS, + SYNC_MODE_30_32_CS, }; enum class Modulation : uint8_t { @@ -218,6 +228,19 @@ enum class HystLevel : uint8_t { HYST_LEVEL_HIGH, }; +enum class PacketFormat : uint8_t { + PACKET_FORMAT_FIFO, + PACKET_FORMAT_SYNC_SERIAL, + PACKET_FORMAT_RANDOM_TX, + PACKET_FORMAT_ASYNC_SERIAL, +}; + +enum class LengthConfig : uint8_t { + LENGTH_CONFIG_FIXED, + LENGTH_CONFIG_VARIABLE, + LENGTH_CONFIG_INFINITE, +}; + struct __attribute__((packed)) CC1101State { // Byte array accessors for bulk SPI transfers uint8_t *regs() { return reinterpret_cast(this); } diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 12a69551f5..fcfafa0c1a 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -7,9 +7,11 @@ BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" CONF_COLOR_DEPTH = "color_depth" +CONF_CRC_ENABLE = "crc_enable" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" CONF_REQUEST_HEADERS = "request_headers" diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 1eb83b7a33..4641db6483 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -1,6 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID, TimePeriod @@ -14,7 +15,6 @@ CONF_SX126X_ID = "sx126x_id" CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_CODING_RATE = "coding_rate" -CONF_CRC_ENABLE = "crc_enable" CONF_CRC_INVERTED = "crc_inverted" CONF_CRC_SIZE = "crc_size" CONF_CRC_POLYNOMIAL = "crc_polynomial" @@ -23,7 +23,6 @@ CONF_DEVIATION = "deviation" CONF_DIO1_PIN = "dio1_pin" CONF_HW_VERSION = "hw_version" CONF_MODULATION = "modulation" -CONF_ON_PACKET = "on_packet" CONF_PA_POWER = "pa_power" CONF_PA_RAMP = "pa_ramp" CONF_PAYLOAD_LENGTH = "payload_length" diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 77cb61f7f8..b569a75972 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -1,6 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID @@ -16,11 +17,9 @@ CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_BITSYNC = "bitsync" CONF_CODING_RATE = "coding_rate" -CONF_CRC_ENABLE = "crc_enable" CONF_DEVIATION = "deviation" CONF_DIO0_PIN = "dio0_pin" CONF_MODULATION = "modulation" -CONF_ON_PACKET = "on_packet" CONF_PA_PIN = "pa_pin" CONF_PA_POWER = "pa_power" CONF_PA_RAMP = "pa_ramp" diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml index 9373ca43e1..93f03e582e 100644 --- a/tests/components/cc1101/common.yaml +++ b/tests/components/cc1101/common.yaml @@ -1,13 +1,26 @@ cc1101: id: transceiver cs_pin: ${cs_pin} + gdo0_pin: ${gdo0_pin} frequency: 433.92MHz if_frequency: 153kHz filter_bandwidth: 203kHz channel: 0 channel_spacing: 200kHz - symbol_rate: 5000 - modulation_type: ASK/OOK + symbol_rate: 4800 + modulation_type: GFSK + packet_mode: true + packet_length: 8 + crc_enable: true + whitening: false + sync_mode: "16/16" + sync0: 0x91 + sync1: 0xD3 + num_preamble: 2 + on_packet: + then: + - lambda: |- + ESP_LOGD("cc1101", "packet %s rssi %.1f dBm lqi %u", format_hex(x).c_str(), rssi, lqi); button: - platform: template @@ -18,3 +31,7 @@ button: - cc1101.begin_rx: transceiver - cc1101.set_idle: transceiver - cc1101.reset: transceiver + - cc1101.send_packet: + data: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef] + - cc1101.send_packet: !lambda |- + return {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; diff --git a/tests/components/cc1101/test.esp32-idf.yaml b/tests/components/cc1101/test.esp32-idf.yaml index e075629679..966f11bb64 100644 --- a/tests/components/cc1101/test.esp32-idf.yaml +++ b/tests/components/cc1101/test.esp32-idf.yaml @@ -1,8 +1,8 @@ substitutions: cs_pin: GPIO5 + gdo0_pin: GPIO4 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml - remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/cc1101/test.esp8266.yaml b/tests/components/cc1101/test.esp8266.yaml index 7900658bc1..6f0f078507 100644 --- a/tests/components/cc1101/test.esp8266.yaml +++ b/tests/components/cc1101/test.esp8266.yaml @@ -1,8 +1,8 @@ substitutions: cs_pin: GPIO5 + gdo0_pin: GPIO4 packages: spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml - remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml <<: !include common.yaml From 780a407b10fc04163fcba4a8e8bc2cbbe5cc5d37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:30:55 -0600 Subject: [PATCH 38/57] [dashboard] Add ESPHOME_TRUSTED_DOMAINS support to events WebSocket (#12479) --- esphome/dashboard/web_server.py | 32 ++++++----- tests/dashboard/test_web_server.py | 87 ++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 804a2b99af..f94d8eea22 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -164,8 +164,24 @@ def websocket_method(name): return wrap +class CheckOriginMixin: + """Mixin to handle WebSocket origin checks for reverse proxy setups.""" + + def check_origin(self, origin: str) -> bool: + if "ESPHOME_TRUSTED_DOMAINS" not in os.environ: + return super().check_origin(origin) + trusted_domains = [ + s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",") + ] + url = urlparse(origin) + if url.hostname in trusted_domains: + return True + _LOGGER.info("check_origin %s, domain is not trusted", origin) + return False + + @websocket_class -class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): +class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): """Base class for ESPHome websocket commands.""" def __init__( @@ -183,18 +199,6 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): # use Popen() with a reading thread instead self._use_popen = os.name == "nt" - def check_origin(self, origin): - if "ESPHOME_TRUSTED_DOMAINS" not in os.environ: - return super().check_origin(origin) - trusted_domains = [ - s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",") - ] - url = urlparse(origin) - if url.hostname in trusted_domains: - return True - _LOGGER.info("check_origin %s, domain is not trusted", origin) - return False - def open(self, *args: str, **kwargs: str) -> None: """Handle new WebSocket connection.""" # Ensure messages from the subprocess are sent immediately @@ -601,7 +605,7 @@ DASHBOARD_SUBSCRIBER = DashboardSubscriber() @websocket_class -class DashboardEventsWebSocket(tornado.websocket.WebSocketHandler): +class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): """WebSocket handler for real-time dashboard events.""" _event_listeners: list[Callable[[], None]] | None = None diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 385841b1c8..10ca6061e6 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1567,3 +1567,90 @@ async def test_dashboard_yaml_loading_with_packages_and_secrets( # If we get here, secret resolution worked! assert "esphome" in config assert config["esphome"]["name"] == "test-download-secrets" + + +@pytest.mark.asyncio +async def test_websocket_check_origin_default_same_origin( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket uses default same-origin check when ESPHOME_TRUSTED_DOMAINS not set.""" + # Ensure ESPHOME_TRUSTED_DOMAINS is not set + env = os.environ.copy() + env.pop("ESPHOME_TRUSTED_DOMAINS", None) + with patch.dict(os.environ, env, clear=True): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + # Same origin should work (default Tornado behavior) + request = HTTPRequest( + url, headers={"Origin": f"http://127.0.0.1:{dashboard.port}"} + ) + ws = await websocket_connect(request) + try: + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + finally: + ws.close() + + +@pytest.mark.asyncio +async def test_websocket_check_origin_trusted_domain( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket accepts connections from trusted domains.""" + with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + request = HTTPRequest(url, headers={"Origin": "https://trusted.example.com"}) + ws = await websocket_connect(request) + try: + # Should receive initial state + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + finally: + ws.close() + + +@pytest.mark.asyncio +async def test_websocket_check_origin_untrusted_domain( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket rejects connections from untrusted domains.""" + with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + request = HTTPRequest(url, headers={"Origin": "https://untrusted.example.com"}) + with pytest.raises(HTTPClientError) as exc_info: + await websocket_connect(request) + # Should get HTTP 403 Forbidden due to origin check failure + assert exc_info.value.code == 403 + + +@pytest.mark.asyncio +async def test_websocket_check_origin_multiple_trusted_domains( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket accepts connections from multiple trusted domains.""" + with patch.dict( + os.environ, + {"ESPHOME_TRUSTED_DOMAINS": "first.example.com, second.example.com"}, + ): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + # Test second domain in list (with space after comma) + request = HTTPRequest(url, headers={"Origin": "https://second.example.com"}) + ws = await websocket_connect(request) + try: + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + finally: + ws.close() From 96e418a8caa6e0ea6a59d1d3a9df5a8827eb9707 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:31:07 -0600 Subject: [PATCH 39/57] [wifi_signal] Skip publishing disconnected RSSI value (#12482) --- esphome/components/wifi_signal/wifi_signal_sensor.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index 5d7f4b4562..9f581f1eb2 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -16,7 +16,12 @@ class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { #ifdef USE_WIFI_LISTENERS void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); } #endif - void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); } + void update() override { + int8_t rssi = wifi::global_wifi_component->wifi_rssi(); + if (rssi != wifi::WIFI_RSSI_DISCONNECTED) { + this->publish_state(rssi); + } + } void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } From 3a5e708c135af4016a64629f0c70cc16b76a5162 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:31:19 -0600 Subject: [PATCH 40/57] [web_server_idf] Always enable LRU purge to prevent socket exhaustion (#12481) --- .../captive_portal/captive_portal.cpp | 6 ------ .../captive_portal/captive_portal.h | 4 ---- .../web_server_idf/web_server_idf.cpp | 19 +++++-------------- .../web_server_idf/web_server_idf.h | 2 -- 4 files changed, 5 insertions(+), 26 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 4eb00835b1..e1f92d2d2b 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -65,12 +65,6 @@ void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { this->base_->add_handler(this); -#ifdef USE_ESP32 - // Enable LRU socket purging to handle captive portal detection probe bursts - // OS captive portal detection makes many simultaneous HTTP requests which can - // exhaust sockets. LRU purging automatically closes oldest idle connections. - this->base_->get_server()->set_lru_purge_enable(true); -#endif } network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index ae9b9dfba0..f48c286f0c 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -40,10 +40,6 @@ class CaptivePortal : public AsyncWebHandler, public Component { void end() { this->active_ = false; this->disable_loop(); // Stop processing DNS requests -#ifdef USE_ESP32 - // Disable LRU socket purging now that captive portal is done - this->base_->get_server()->set_lru_purge_enable(false); -#endif this->base_->deinit(); if (this->dns_server_ != nullptr) { this->dns_server_->stop(); diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index af99b85e53..8c3ad288c0 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -117,18 +117,6 @@ void AsyncWebServer::end() { } } -void AsyncWebServer::set_lru_purge_enable(bool enable) { - if (this->lru_purge_enable_ == enable) { - return; // No change needed - } - this->lru_purge_enable_ = enable; - // If server is already running, restart it with new config - if (this->server_) { - this->end(); - this->begin(); - } -} - void AsyncWebServer::begin() { if (this->server_) { this->end(); @@ -136,8 +124,11 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = this->port_; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; - // Enable LRU purging if requested (e.g., by captive portal to handle probe bursts) - config.lru_purge_enable = this->lru_purge_enable_; + // Always enable LRU purging to handle socket exhaustion gracefully. + // When max sockets is reached, the oldest connection is closed to make room for new ones. + // This prevents "httpd_accept_conn: error in accept (23)" errors. + // See: https://github.com/esphome/esphome/issues/12464 + config.lru_purge_enable = true; // Use custom close function that shuts down before closing to prevent lwIP race conditions config.close_fn = AsyncWebServer::safe_close_with_shutdown; if (httpd_start(&this->server_, &config) == ESP_OK) { diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index a139e9e4df..5f9f598388 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -199,13 +199,11 @@ class AsyncWebServer { return *handler; } - void set_lru_purge_enable(bool enable); httpd_handle_t get_server() { return this->server_; } protected: uint16_t port_{}; httpd_handle_t server_{}; - bool lru_purge_enable_{false}; static esp_err_t request_handler(httpd_req_t *r); static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; From 8524b894d637b936a5b8dc3bc652f2ce4f2e0b8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:47:11 -0600 Subject: [PATCH 41/57] [ota] Match client timeout to device timeout to prevent premature failures (#12484) --- esphome/espota2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index c29506224c..6349ad0fa8 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -322,8 +322,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) - # Set higher timeout during upload - sock.settimeout(30.0) + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures + sock.settimeout(90.0) upload_size = len(upload_contents) upload_size_encoded = [ From cee532a1e3234f8ea2a12934d6587db5f896d980 Mon Sep 17 00:00:00 2001 From: guillempages Date: Sun, 14 Dec 2025 21:15:19 +0100 Subject: [PATCH 42/57] [core] Use Arduino string macros only on ESP8266 (#12471) --- esphome/core/progmem.h | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 67131fd113..f9508945e8 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -1,16 +1,16 @@ #pragma once // Platform-agnostic macros for PROGMEM string handling -// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM) // On ESP8266/Arduino: Use Arduino's F() macro for PROGMEM strings +// On other platforms: Use plain strings (no PROGMEM) -#ifdef USE_ESP32 -#define ESPHOME_F(string_literal) (string_literal) -#define ESPHOME_PGM_P const char * -#define ESPHOME_strncpy_P strncpy -#else -// ESP8266 and other Arduino platforms use Arduino macros +#ifdef USE_ESP8266 +// ESP8266 uses Arduino macros #define ESPHOME_F(string_literal) F(string_literal) #define ESPHOME_PGM_P PGM_P #define ESPHOME_strncpy_P strncpy_P +#else +#define ESPHOME_F(string_literal) (string_literal) +#define ESPHOME_PGM_P const char * +#define ESPHOME_strncpy_P strncpy #endif From fa5b14fad43273ad01d6ed247bca721c6fc7b216 Mon Sep 17 00:00:00 2001 From: mbohdal Date: Sun, 14 Dec 2025 22:40:08 +0100 Subject: [PATCH 43/57] [ethernet] fix used pins validation in configuration of RMII pins (#12486) --- esphome/components/ethernet/__init__.py | 5 ++++- .../test-lan8720-with-expander.esp32-idf.yaml | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index b4b1fcd9f6..e1ed327fb9 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -434,10 +434,13 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: # Check all used pins against RMII reserved pins for pin_list in pins.PIN_SCHEMA_REGISTRY.pins_used.values(): - for pin_path, _, pin_config in pin_list: + for pin_path, pin_device, pin_config in pin_list: pin_num = pin_config.get(CONF_NUMBER) if pin_num not in rmii_pins: continue + # Skip if pin is not directly on ESP, but at some expander (device set to something else than 'None') + if pin_device is not None: + continue # Found a conflict - show helpful error message pin_function = rmii_pins[pin_num] component_path = ".".join(str(p) for p in pin_path) diff --git a/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml b/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml new file mode 100644 index 0000000000..09da8d90d9 --- /dev/null +++ b/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml @@ -0,0 +1,15 @@ +<<: !include common-lan8720.yaml + +sn74hc165: + - id: sn74hc165_hub + clock_pin: GPIO13 + data_pin: GPIO14 + load_pin: GPIO15 + sr_count: 3 + +binary_sensor: + - platform: gpio + pin: + sn74hc165: sn74hc165_hub + number: 19 + id: relay_2 From ffce80f96cf3348537401642f84b5a49ce95fa69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 16:26:34 -0600 Subject: [PATCH 44/57] [wifi] Fix WiFi recovery after failed connection attempts (#12483) --- esphome/components/wifi/wifi_component.cpp | 26 ++++++++++++++++--- .../wifi/wifi_component_esp_idf.cpp | 1 + .../wifi/wifi_component_libretiny.cpp | 10 +++++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d46916bfd9..a5e8c4a59d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -205,6 +205,21 @@ static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; /// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000; +/// Timeout for WiFi scan operations +/// This is a fallback in case we don't receive a scan done callback from the WiFi driver. +/// Normal scans complete via callback; this only triggers if something goes wrong. +static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000; + +/// Timeout for WiFi connection attempts +/// This is a fallback in case we don't receive connection success/failure callbacks. +/// Some platforms (especially LibreTiny/Beken) can take 30-60 seconds to connect, +/// particularly with fast_connect enabled where no prior scan provides channel info. +/// Do not lower this value - connection failures are detected via callbacks, not timeout. +/// If this timeout fires prematurely while a connection is still in progress, it causes +/// cascading failures: the subsequent scan will also fail because the WiFi driver is +/// still busy with the previous connection attempt. +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000; + static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { case WiFiRetryPhase::INITIAL_CONNECT: @@ -1035,7 +1050,7 @@ __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) void WiFiComponent::check_scanning_finished() { if (!this->scan_done_) { - if (millis() - this->action_started_ > 30000) { + if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) { ESP_LOGE(TAG, "Scan timeout"); this->retry_connect(); } @@ -1184,8 +1199,9 @@ void WiFiComponent::check_connecting_finished() { } uint32_t now = millis(); - if (now - this->action_started_ > 30000) { - ESP_LOGW(TAG, "Connection timeout"); + if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Connection timeout, aborting connection attempt"); + this->wifi_disconnect_(); this->retry_connect(); return; } @@ -1405,6 +1421,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // without disrupting the captive portal/improv connection if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { this->restart_adapter(); + } else { + // Even when skipping full restart, disconnect to clear driver state + // Without this, platforms like LibreTiny may think we're still connecting + this->wifi_disconnect_(); } // Clear scan flag - we're starting a new retry cycle this->did_scan_this_cycle_ = false; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 1f4eb1e42c..4a3c40a119 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -720,6 +720,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); s_sta_started = false; + s_sta_connecting = false; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { const auto &it = data->data.sta_authmode_change; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 4fd64bdfa3..36003a6eb4 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -291,6 +291,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); + s_sta_connecting = false; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -322,7 +323,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ // wifi_sta_connect_status_() to return IDLE. The main loop then sees // "Unknown connection status 0" (wifi_component.cpp check_connecting_finished) // and calls retry_connect(), aborting a connection that may succeed moments later. - // Real connection failures will have ssid/bssid populated, or we'll hit the 30s timeout. + // Real connection failures will have ssid/bssid populated, or we'll hit the connection timeout. if (it.ssid_len == 0 && s_sta_connecting) { ESP_LOGV(TAG, "Ignoring disconnect event with empty ssid while connecting (reason=%s)", get_disconnect_reason_str(it.reason)); @@ -527,7 +528,12 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; } #endif // USE_WIFI_AP -bool WiFiComponent::wifi_disconnect_() { return WiFi.disconnect(); } +bool WiFiComponent::wifi_disconnect_() { + // Clear connecting flag first so disconnect events aren't ignored + // and wifi_sta_connect_status_() returns IDLE instead of CONNECTING + s_sta_connecting = false; + return WiFi.disconnect(); +} bssid_t WiFiComponent::wifi_bssid() { bssid_t bssid{}; From c69d58273a0b6a2eefc034158e5bf7c1411f9359 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 07:38:31 -0600 Subject: [PATCH 45/57] [core] Fix CORE.raw_config not updated after package merge (#12456) --- esphome/config.py | 4 +-- .../component_tests/packages/test_packages.py | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 694716be34..6f6ad4886b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1010,14 +1010,14 @@ def validate_config( result.add_error(err) return result - CORE.raw_config = config - # 1.1. Merge packages if CONF_PACKAGES in config: from esphome.components.packages import merge_packages config = merge_packages(config) + CORE.raw_config = config + # 1.2. Resolve !extend and !remove and check for REPLACEME # After this step, there will not be any Extend or Remove values in the config anymore try: diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 3829e540d7..22fb2c4e32 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages +import esphome.config as config_module from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv @@ -33,6 +34,7 @@ from esphome.const import ( CONF_VARS, CONF_WIFI, ) +from esphome.core import CORE from esphome.util import OrderedDict # Test strings @@ -991,3 +993,35 @@ def test_package_merge_invalid(invalid_package) -> None: with pytest.raises(cv.Invalid): merge_packages(config) + + +def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: + """Test that CORE.raw_config contains esphome section from merged package. + + This is a regression test for the bug where CORE.raw_config was set before + packages were merged, causing KeyError when components accessed + CORE.raw_config[CONF_ESPHOME] and the esphome section came from a package. + """ + # Create a config where esphome section comes from a package + test_config = OrderedDict() + test_config[CONF_PACKAGES] = { + "base": { + CONF_ESPHOME: {CONF_NAME: TEST_DEVICE_NAME}, + } + } + test_config["esp32"] = {"board": "esp32dev"} + + # Set up CORE for the test + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("# test config") + CORE.reset() + CORE.config_path = test_yaml + + # Call validate_config - this should merge packages and set CORE.raw_config + config_module.validate_config(test_config, {}) + + # Verify that CORE.raw_config contains the esphome section from the package + assert CONF_ESPHOME in CORE.raw_config, ( + "CORE.raw_config should contain esphome section after package merge" + ) + assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME From 4da95ccd7e6ed96d7932b22528457c6fbcc0dde7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:47:15 +1000 Subject: [PATCH 46/57] [packet_transport] Ensure retransmission at update intervals (#12472) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../packet_transport/espnow_transport.cpp | 11 +++-------- .../espnow/packet_transport/espnow_transport.h | 1 - esphome/components/packet_transport/__init__.py | 5 +++++ .../packet_transport/packet_transport.cpp | 6 +++++- .../packet_transport/packet_transport.h | 3 ++- .../packet_transport/sx126x_transport.cpp | 6 ------ .../sx126x/packet_transport/sx126x_transport.h | 1 - .../packet_transport/sx127x_transport.cpp | 6 ------ .../sx127x/packet_transport/sx127x_transport.h | 1 - .../uart/packet_transport/uart_transport.cpp | 6 ------ .../uart/packet_transport/uart_transport.h | 1 - .../udp/packet_transport/udp_transport.cpp | 17 +---------------- .../udp/packet_transport/udp_transport.h | 2 -- tests/components/espnow/common.yaml | 4 ++-- 14 files changed, 18 insertions(+), 52 deletions(-) diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index d30e9447a0..c1252acc9d 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -13,7 +13,7 @@ static const char *const TAG = "espnow.transport"; bool ESPNowTransport::should_send() { return this->parent_ != nullptr && !this->parent_->is_failed(); } void ESPNowTransport::setup() { - packet_transport::PacketTransport::setup(); + PacketTransport::setup(); if (this->parent_ == nullptr) { ESP_LOGE(TAG, "ESPNow component not set"); @@ -26,15 +26,10 @@ void ESPNowTransport::setup() { this->peer_address_[2], this->peer_address_[3], this->peer_address_[4], this->peer_address_[5]); // Register received handler - this->parent_->register_received_handler(static_cast(this)); + this->parent_->register_received_handler(this); // Register broadcasted handler - this->parent_->register_broadcasted_handler(static_cast(this)); -} - -void ESPNowTransport::update() { - packet_transport::PacketTransport::update(); - this->updated_ = true; + this->parent_->register_broadcasted_handler(this); } void ESPNowTransport::send_packet(const std::vector &buf) const { diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 3629fad2cd..d85119db7d 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -18,7 +18,6 @@ class ESPNowTransport : public packet_transport::PacketTransport, public ESPNowBroadcastedHandler { public: void setup() override; - void update() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void set_peer_address(peer_address_t address) { diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 43da7740fe..1930e45e85 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -176,17 +176,22 @@ async def register_packet_transport(var, config): if encryption := provider.get(CONF_ENCRYPTION): cg.add(var.set_provider_encryption(name, hash_encryption_key(encryption))) + is_provider = False for sens_conf in config.get(CONF_SENSORS, ()): + is_provider = True sens_id = sens_conf[CONF_ID] sensor = await cg.get_variable(sens_id) bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id) cg.add(var.add_sensor(bcst_id, sensor)) for sens_conf in config.get(CONF_BINARY_SENSORS, ()): + is_provider = True sens_id = sens_conf[CONF_ID] sensor = await cg.get_variable(sens_id) bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id) cg.add(var.add_binary_sensor(bcst_id, sensor)) + if is_provider: + cg.add(var.set_is_provider(True)) if encryption := config.get(CONF_ENCRYPTION): cg.add(var.set_encryption_key(hash_encryption_key(encryption))) return providers diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 37e5f3d9e1..da7f5f8bff 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -263,6 +263,7 @@ void PacketTransport::flush_() { xxtea::encrypt((uint32_t *) (encode_buffer.data() + header_len), len / 4, (uint32_t *) this->encryption_key_.data()); } + ESP_LOGVV(TAG, "Sending packet %s", format_hex_pretty(encode_buffer.data(), encode_buffer.size()).c_str()); this->send_packet(encode_buffer); } @@ -316,6 +317,9 @@ void PacketTransport::send_data_(bool all) { } void PacketTransport::update() { + // resend all sensors if required + if (this->is_provider_) + this->send_data_(true); if (!this->ping_pong_enable_) { return; } @@ -551,7 +555,7 @@ void PacketTransport::loop() { if (this->resend_ping_key_) this->send_ping_pong_request_(); if (this->updated_) { - this->send_data_(this->resend_data_); + this->send_data_(false); } } diff --git a/esphome/components/packet_transport/packet_transport.h b/esphome/components/packet_transport/packet_transport.h index a2370e9749..86ec564fce 100644 --- a/esphome/components/packet_transport/packet_transport.h +++ b/esphome/components/packet_transport/packet_transport.h @@ -91,6 +91,7 @@ class PacketTransport : public PollingComponent { } } + void set_is_provider(bool is_provider) { this->is_provider_ = is_provider; } void set_encryption_key(std::vector key) { this->encryption_key_ = std::move(key); } void set_rolling_code_enable(bool enable) { this->rolling_code_enable_ = enable; } void set_ping_pong_enable(bool enable) { this->ping_pong_enable_ = enable; } @@ -129,7 +130,7 @@ class PacketTransport : public PollingComponent { uint32_t ping_pong_recyle_time_{}; uint32_t last_key_time_{}; bool resend_ping_key_{}; - bool resend_data_{}; + bool is_provider_{}; const char *name_{}; ESPPreferenceObject pref_{}; diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp index 2cfc4b700e..59d80bd297 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp @@ -12,12 +12,6 @@ void SX126xTransport::setup() { this->parent_->register_listener(this); } -void SX126xTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = true; -} - void SX126xTransport::send_packet(const std::vector &buf) const { this->parent_->transmit_packet(buf); } void SX126xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.h b/esphome/components/sx126x/packet_transport/sx126x_transport.h index 755d30417d..640c6a76f9 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.h +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.h @@ -11,7 +11,6 @@ namespace sx126x { class SX126xTransport : public packet_transport::PacketTransport, public Parented, public SX126xListener { public: void setup() override; - void update() override; void on_packet(const std::vector &packet, float rssi, float snr) override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp index b1d014bb96..893726e816 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp @@ -12,12 +12,6 @@ void SX127xTransport::setup() { this->parent_->register_listener(this); } -void SX127xTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = true; -} - void SX127xTransport::send_packet(const std::vector &buf) const { this->parent_->transmit_packet(buf); } void SX127xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.h b/esphome/components/sx127x/packet_transport/sx127x_transport.h index e27b7f8d57..6208372971 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.h +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.h @@ -11,7 +11,6 @@ namespace sx127x { class SX127xTransport : public packet_transport::PacketTransport, public Parented, public SX127xListener { public: void setup() override; - void update() override; void on_packet(const std::vector &packet, float rssi, float snr) override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/uart/packet_transport/uart_transport.cpp b/esphome/components/uart/packet_transport/uart_transport.cpp index 4a9aa0fe47..6b8eae611c 100644 --- a/esphome/components/uart/packet_transport/uart_transport.cpp +++ b/esphome/components/uart/packet_transport/uart_transport.cpp @@ -55,12 +55,6 @@ void UARTTransport::loop() { } } -void UARTTransport::update() { - this->updated_ = true; - this->resend_data_ = true; - PacketTransport::update(); -} - /** * Write a byte to the UART bus. If the byte is a flag or control byte, it will be escaped. * @param byte The byte to write. diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index e84bed95e6..1c92af536e 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -23,7 +23,6 @@ static const uint8_t CONTROL_BYTE = 0x7D; class UARTTransport : public packet_transport::PacketTransport, public UARTDevice { public: void loop() override; - void update() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } protected: diff --git a/esphome/components/udp/packet_transport/udp_transport.cpp b/esphome/components/udp/packet_transport/udp_transport.cpp index b92e0d64df..f3e33573a5 100644 --- a/esphome/components/udp/packet_transport/udp_transport.cpp +++ b/esphome/components/udp/packet_transport/udp_transport.cpp @@ -8,29 +8,14 @@ namespace udp { static const char *const TAG = "udp_transport"; -bool UDPTransport::should_send() { return this->should_broadcast_ && network::is_connected(); } +bool UDPTransport::should_send() { return network::is_connected(); } void UDPTransport::setup() { PacketTransport::setup(); - this->should_broadcast_ = this->ping_pong_enable_; -#ifdef USE_SENSOR - this->should_broadcast_ |= !this->sensors_.empty(); -#endif -#ifdef USE_BINARY_SENSOR - this->should_broadcast_ |= !this->binary_sensors_.empty(); -#endif - if (this->should_broadcast_) - this->parent_->set_should_broadcast(); if (!this->providers_.empty() || this->is_encrypted_()) { this->parent_->add_listener([this](std::vector &buf) { this->process_(buf); }); } } -void UDPTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = this->should_broadcast_; -} - void UDPTransport::send_packet(const std::vector &buf) const { this->parent_->send_packet(buf); } } // namespace udp } // namespace esphome diff --git a/esphome/components/udp/packet_transport/udp_transport.h b/esphome/components/udp/packet_transport/udp_transport.h index c87eb62780..8d01ae0909 100644 --- a/esphome/components/udp/packet_transport/udp_transport.h +++ b/esphome/components/udp/packet_transport/udp_transport.h @@ -12,14 +12,12 @@ namespace udp { class UDPTransport : public packet_transport::PacketTransport, public Parented { public: void setup() override; - void update() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } protected: void send_packet(const std::vector &buf) const override; bool should_send() override; - bool should_broadcast_{false}; size_t get_max_packet_size() override { return MAX_PACKET_SIZE; } }; diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 895ffb9d15..b724af54e0 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: sensors: - temp_sensor providers: - - name: test_provider + - name: test-provider encryption: key: "0123456789abcdef0123456789abcdef" @@ -71,6 +71,6 @@ sensor: id: temp_sensor - platform: packet_transport - provider: test_provider + provider: test-provider remote_id: temp_sensor id: remote_temp From 359f45400f717b4fb00869c9b168d28604544c7e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:47:52 -0500 Subject: [PATCH 47/57] [core] Fix polling_component_schema and type consistency (#12478) Co-authored-by: Claude --- esphome/config_validation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c52b791120..08fffa6cec 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -71,6 +71,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, + SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, VALID_SUBSTITUTIONS_CHARACTERS, @@ -894,7 +895,7 @@ def time_period_in_minutes_(value): def update_interval(value): if value == "never": - return 4294967295 # uint32_t max + return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) return positive_time_period_milliseconds(value) @@ -2009,7 +2010,7 @@ def polling_component_schema(default_update_interval): if default_update_interval is None: return COMPONENT_SCHEMA.extend( { - Required(CONF_UPDATE_INTERVAL): default_update_interval, + Required(CONF_UPDATE_INTERVAL): update_interval, } ) assert isinstance(default_update_interval, str) From 46574fcbeceecf2ea6698a80fef0ceee5d2c99fe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Dec 2025 13:22:55 -0500 Subject: [PATCH 48/57] [cc1101] Add packet mode support (#12474) Co-authored-by: Claude --- esphome/components/cc1101/__init__.py | 105 +++++++++++- esphome/components/cc1101/cc1101.cpp | 171 +++++++++++++++++--- esphome/components/cc1101/cc1101.h | 49 +++++- esphome/components/cc1101/cc1101defs.h | 23 +++ esphome/components/const/__init__.py | 2 + esphome/components/sx126x/__init__.py | 3 +- esphome/components/sx127x/__init__.py | 3 +- tests/components/cc1101/common.yaml | 21 ++- tests/components/cc1101/test.esp32-idf.yaml | 2 +- tests/components/cc1101/test.esp8266.yaml | 2 +- 10 files changed, 340 insertions(+), 41 deletions(-) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index e6b31b84f8..1971817fb1 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -1,9 +1,17 @@ -from esphome import automation +from esphome import automation, pins from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_FREQUENCY, CONF_ID, CONF_WAIT_TIME +from esphome.const import ( + CONF_CHANNEL, + CONF_DATA, + CONF_FREQUENCY, + CONF_ID, + CONF_WAIT_TIME, +) +from esphome.core import ID CODEOWNERS = ["@lygris", "@gabest11"] DEPENDENCIES = ["spi"] @@ -29,7 +37,6 @@ CONF_MANCHESTER = "manchester" CONF_NUM_PREAMBLE = "num_preamble" CONF_SYNC1 = "sync1" CONF_SYNC0 = "sync0" -CONF_PKTLEN = "pktlen" CONF_MAGN_TARGET = "magn_target" CONF_MAX_LNA_GAIN = "max_lna_gain" CONF_MAX_DVGA_GAIN = "max_dvga_gain" @@ -41,6 +48,12 @@ CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook" CONF_FREEZE = "freeze" CONF_HYST_LEVEL = "hyst_level" +# Packet mode config keys +CONF_PACKET_MODE = "packet_mode" +CONF_PACKET_LENGTH = "packet_length" +CONF_WHITENING = "whitening" +CONF_GDO0_PIN = "gdo0_pin" + # Enums SyncMode = ns.enum("SyncMode", True) SYNC_MODE = { @@ -167,7 +180,6 @@ CONFIG_MAP = { CONF_NUM_PREAMBLE: cv.int_range(min=0, max=7), CONF_SYNC1: cv.hex_uint8_t, CONF_SYNC0: cv.hex_uint8_t, - CONF_PKTLEN: cv.uint8_t, CONF_MAGN_TARGET: cv.enum(MAGN_TARGET, upper=False), CONF_MAX_LNA_GAIN: cv.enum(MAX_LNA_GAIN, upper=False), CONF_MAX_DVGA_GAIN: cv.enum(MAX_DVGA_GAIN, upper=False), @@ -179,13 +191,36 @@ CONFIG_MAP = { CONF_FREEZE: cv.enum(FREEZE, upper=False), CONF_WAIT_TIME: cv.enum(WAIT_TIME, upper=False), CONF_HYST_LEVEL: cv.enum(HYST_LEVEL, upper=False), + CONF_PACKET_MODE: cv.boolean, + CONF_PACKET_LENGTH: cv.uint8_t, + CONF_CRC_ENABLE: cv.boolean, + CONF_WHITENING: cv.boolean, } -CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(CC1101Component)}) + +def _validate_packet_mode(config): + if config.get(CONF_PACKET_MODE, False): + if CONF_GDO0_PIN not in config: + raise cv.Invalid("gdo0_pin is required when packet_mode is enabled") + if CONF_PACKET_LENGTH not in config: + raise cv.Invalid("packet_length is required when packet_mode is enabled") + if config[CONF_PACKET_LENGTH] > 64: + raise cv.Invalid("packet_length must be <= 64 (FIFO size)") + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CC1101Component), + cv.Optional(CONF_GDO0_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), + } + ) .extend({cv.Optional(key): validator for key, validator in CONFIG_MAP.items()}) .extend(cv.COMPONENT_SCHEMA) - .extend(spi.spi_device_schema(cs_pin_required=True)) + .extend(spi.spi_device_schema(cs_pin_required=True)), + _validate_packet_mode, ) @@ -198,12 +233,29 @@ async def to_code(config): if key in config: cg.add(getattr(var, f"set_{key}")(config[key])) + if CONF_GDO0_PIN in config: + gdo0_pin = await cg.gpio_pin_expression(config[CONF_GDO0_PIN]) + cg.add(var.set_gdo0_pin(gdo0_pin)) + if CONF_ON_PACKET in config: + await automation.build_automation( + var.get_packet_trigger(), + [ + (cg.std_vector.template(cg.uint8), "x"), + (cg.float_, "rssi"), + (cg.uint8, "lqi"), + ], + config[CONF_ON_PACKET], + ) + # Actions BeginTxAction = ns.class_("BeginTxAction", automation.Action) BeginRxAction = ns.class_("BeginRxAction", automation.Action) ResetAction = ns.class_("ResetAction", automation.Action) SetIdleAction = ns.class_("SetIdleAction", automation.Action) +SendPacketAction = ns.class_( + "SendPacketAction", automation.Action, cg.Parented.template(CC1101Component) +) CC1101_ACTION_SCHEMA = cv.Schema( maybe_simple_id({cv.GenerateID(CONF_ID): cv.use_id(CC1101Component)}) @@ -218,3 +270,42 @@ async def cc1101_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var + + +def validate_raw_data(value): + if isinstance(value, str): + return value.encode("utf-8") + if isinstance(value, list): + return cv.Schema([cv.hex_uint8_t])(value) + raise cv.Invalid( + "data must either be a string wrapped in quotes or a list of bytes" + ) + + +SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(CC1101Component), + cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + }, + key=CONF_DATA, +) + + +@automation.register_action( + "cc1101.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA +) +async def send_packet_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + data = config[CONF_DATA] + if isinstance(data, bytes): + data = list(data) + if cg.is_template(data): + templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) + cg.add(var.set_data_template(templ)) + else: + # Generate static array in flash to avoid RAM copy + arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) + cg.add(var.set_data_static(arr, len(data))) + return var diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 3cbf09ded8..5b6eb545bc 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -143,6 +143,11 @@ void CC1101Component::setup() { return; } + // Setup GDO0 pin if configured + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->setup(); + } + this->initialized_ = true; for (uint8_t i = 0; i <= static_cast(Register::TEST0); i++) { @@ -151,8 +156,69 @@ void CC1101Component::setup() { } this->write_(static_cast(i)); } - this->write_(Register::PATABLE, this->pa_table_, sizeof(this->pa_table_)); + this->set_output_power(this->output_power_requested_); this->strobe_(Command::RX); + + // Defer pin mode setup until after all components have completed setup() + // This handles the case where remote_transmitter runs after CC1101 and changes pin mode + if (this->gdo0_pin_ != nullptr) { + this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); }); + } +} + +void CC1101Component::loop() { + if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr || + !this->gdo0_pin_->digital_read()) { + return; + } + + // Read state + this->read_(Register::RXBYTES); + uint8_t rx_bytes = this->state_.NUM_RXBYTES; + bool overflow = this->state_.RXFIFO_OVERFLOW; + if (overflow || rx_bytes == 0) { + ESP_LOGW(TAG, "RX FIFO overflow, flushing"); + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return; + } + + // Read packet + uint8_t payload_length; + if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { + this->read_(Register::FIFO, &payload_length, 1); + } else { + payload_length = this->state_.PKTLEN; + } + if (payload_length == 0 || payload_length > 64) { + ESP_LOGW(TAG, "Invalid payload length: %u", payload_length); + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return; + } + this->packet_.resize(payload_length); + this->read_(Register::FIFO, this->packet_.data(), payload_length); + + // Read status and trigger + uint8_t status[2]; + this->read_(Register::FIFO, status, 2); + int8_t rssi_raw = static_cast(status[0]); + float rssi = (rssi_raw * RSSI_STEP) - RSSI_OFFSET; + bool crc_ok = (status[1] & STATUS_CRC_OK_MASK) != 0; + uint8_t lqi = status[1] & STATUS_LQI_MASK; + if (this->state_.CRC_EN == 0 || crc_ok) { + this->packet_trigger_->trigger(this->packet_, rssi, lqi); + } + + // Return to rx + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); } void CC1101Component::dump_config() { @@ -177,9 +243,12 @@ void CC1101Component::dump_config() { } void CC1101Component::begin_tx() { - // Ensure Packet Format is 3 (Async Serial), use GDO0 as input during TX + // Ensure Packet Format is 3 (Async Serial) this->write_(Register::PKTCTRL0, 0x32); ESP_LOGV(TAG, "Beginning TX sequence"); + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); + } this->strobe_(Command::TX); if (!this->wait_for_state_(State::TX, 50)) { ESP_LOGW(TAG, "Timed out waiting for TX state!"); @@ -188,6 +257,9 @@ void CC1101Component::begin_tx() { void CC1101Component::begin_rx() { ESP_LOGV(TAG, "Beginning RX sequence"); + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); + } this->strobe_(Command::RX); } @@ -201,20 +273,6 @@ void CC1101Component::set_idle() { this->enter_idle_(); } -void CC1101Component::set_gdo0_config(uint8_t value) { - this->state_.GDO0_CFG = value; - if (this->initialized_) { - this->write_(Register::IOCFG0); - } -} - -void CC1101Component::set_gdo2_config(uint8_t value) { - this->state_.GDO2_CFG = value; - if (this->initialized_) { - this->write_(Register::IOCFG2); - } -} - bool CC1101Component::wait_for_state_(State target_state, uint32_t timeout_ms) { uint32_t start = millis(); while (millis() - start < timeout_ms) { @@ -282,6 +340,33 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) { this->disable(); } +CC1101Error CC1101Component::transmit_packet(const std::vector &packet) { + if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { + return CC1101Error::PARAMS; + } + + // Write packet + this->enter_idle_(); + this->strobe_(Command::FTX); + if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { + this->write_(Register::FIFO, static_cast(packet.size())); + } + this->write_(Register::FIFO, packet.data(), packet.size()); + this->strobe_(Command::TX); + if (!this->wait_for_state_(State::IDLE, 1000)) { + ESP_LOGW(TAG, "TX timeout"); + this->enter_idle_(); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return CC1101Error::TIMEOUT; + } + + // Return to rx + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return CC1101Error::NONE; +} + // Setters void CC1101Component::set_output_power(float value) { this->output_power_requested_ = value; @@ -428,6 +513,7 @@ void CC1101Component::set_modulation_type(Modulation value) { this->state_.PA_POWER = value == Modulation::MODULATION_ASK_OOK ? 1 : 0; if (this->initialized_) { this->enter_idle_(); + this->set_output_power(this->output_power_requested_); this->write_(Register::MDMCFG2); this->write_(Register::FREND0); this->strobe_(Command::RX); @@ -462,13 +548,6 @@ void CC1101Component::set_sync0(uint8_t value) { } } -void CC1101Component::set_pktlen(uint8_t value) { - this->state_.PKTLEN = value; - if (this->initialized_) { - this->write_(Register::PKTLEN); - } -} - void CC1101Component::set_magn_target(MagnTarget value) { this->state_.MAGN_TARGET = static_cast(value); if (this->initialized_) { @@ -546,4 +625,50 @@ void CC1101Component::set_hyst_level(HystLevel value) { } } +void CC1101Component::set_packet_mode(bool value) { + this->state_.PKT_FORMAT = + static_cast(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL); + if (value) { + // Configure GDO0 for FIFO status (asserts on RX FIFO threshold or end of packet) + this->state_.GDO0_CFG = 0x01; + // Set max RX FIFO threshold to ensure we only trigger on end-of-packet + this->state_.FIFO_THR = 15; + } else { + // Configure GDO0 for serial data (async serial mode) + this->state_.GDO0_CFG = 0x0D; + } + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + this->write_(Register::IOCFG0); + this->write_(Register::FIFOTHR); + } +} + +void CC1101Component::set_packet_length(uint8_t value) { + if (value == 0) { + this->state_.LENGTH_CONFIG = static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE); + } else { + this->state_.LENGTH_CONFIG = static_cast(LengthConfig::LENGTH_CONFIG_FIXED); + this->state_.PKTLEN = value; + } + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + this->write_(Register::PKTLEN); + } +} + +void CC1101Component::set_crc_enable(bool value) { + this->state_.CRC_EN = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + } +} + +void CC1101Component::set_whitening(bool value) { + this->state_.WHITE_DATA = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + } +} + } // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 65aeb2ea82..b896f7e974 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -5,9 +5,12 @@ #include "esphome/components/spi/spi.h" #include "esphome/core/automation.h" #include "cc1101defs.h" +#include namespace esphome::cc1101 { +enum class CC1101Error { NONE = 0, TIMEOUT, PARAMS, CRC_ERROR, FIFO_OVERFLOW }; + class CC1101Component : public Component, public spi::SPIDevice { @@ -15,6 +18,7 @@ class CC1101Component : public Component, CC1101Component(); void setup() override; + void loop() override; void dump_config() override; // Actions @@ -24,8 +28,7 @@ class CC1101Component : public Component, void set_idle(); // GDO Pin Configuration - void set_gdo0_config(uint8_t value); - void set_gdo2_config(uint8_t value); + void set_gdo0_pin(InternalGPIOPin *pin) { this->gdo0_pin_ = pin; } // Configuration Setters void set_output_power(float value); @@ -48,7 +51,6 @@ class CC1101Component : public Component, void set_num_preamble(uint8_t value); void set_sync1(uint8_t value); void set_sync0(uint8_t value); - void set_pktlen(uint8_t value); // AGC settings void set_magn_target(MagnTarget value); @@ -63,6 +65,16 @@ class CC1101Component : public Component, void set_wait_time(WaitTime value); void set_hyst_level(HystLevel value); + // Packet mode settings + void set_packet_mode(bool value); + void set_packet_length(uint8_t value); + void set_crc_enable(bool value); + void set_whitening(bool value); + + // Packet mode operations + CC1101Error transmit_packet(const std::vector &packet); + Trigger, float, uint8_t> *get_packet_trigger() const { return this->packet_trigger_; } + protected: uint16_t chip_id_{0}; bool initialized_{false}; @@ -73,6 +85,13 @@ class CC1101Component : public Component, CC1101State state_; + // GDO pin for packet reception + InternalGPIOPin *gdo0_pin_{nullptr}; + + // Packet handling + Trigger, float, uint8_t> *packet_trigger_{new Trigger, float, uint8_t>()}; + std::vector packet_; + // Low-level Helpers uint8_t strobe_(Command cmd); void write_(Register reg); @@ -107,4 +126,28 @@ template class SetIdleAction : public Action, public Pare void play(const Ts &...x) override { this->parent_->set_idle(); } }; +template class SendPacketAction : public Action, public Parented { + public: + void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } + void set_data_static(const uint8_t *data, size_t len) { + this->data_static_ = data; + this->data_static_len_ = len; + } + + void play(const Ts &...x) override { + if (this->data_func_) { + auto data = this->data_func_(x...); + this->parent_->transmit_packet(data); + } else if (this->data_static_ != nullptr) { + std::vector data(this->data_static_, this->data_static_ + this->data_static_len_); + this->parent_->transmit_packet(data); + } + } + + protected: + std::function(Ts...)> data_func_{}; + const uint8_t *data_static_{nullptr}; + size_t data_static_len_{0}; +}; + } // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h index afeb5f1d77..1bc42f5859 100644 --- a/esphome/components/cc1101/cc1101defs.h +++ b/esphome/components/cc1101/cc1101defs.h @@ -6,6 +6,12 @@ namespace esphome::cc1101 { static constexpr float XTAL_FREQUENCY = 26000000; +static constexpr float RSSI_OFFSET = 74.0f; +static constexpr float RSSI_STEP = 0.5f; + +static constexpr uint8_t STATUS_CRC_OK_MASK = 0x80; +static constexpr uint8_t STATUS_LQI_MASK = 0x7F; + static constexpr uint8_t BUS_BURST = 0x40; static constexpr uint8_t BUS_READ = 0x80; static constexpr uint8_t BUS_WRITE = 0x00; @@ -134,6 +140,10 @@ enum class SyncMode : uint8_t { SYNC_MODE_15_16, SYNC_MODE_16_16, SYNC_MODE_30_32, + SYNC_MODE_NONE_CS, + SYNC_MODE_15_16_CS, + SYNC_MODE_16_16_CS, + SYNC_MODE_30_32_CS, }; enum class Modulation : uint8_t { @@ -218,6 +228,19 @@ enum class HystLevel : uint8_t { HYST_LEVEL_HIGH, }; +enum class PacketFormat : uint8_t { + PACKET_FORMAT_FIFO, + PACKET_FORMAT_SYNC_SERIAL, + PACKET_FORMAT_RANDOM_TX, + PACKET_FORMAT_ASYNC_SERIAL, +}; + +enum class LengthConfig : uint8_t { + LENGTH_CONFIG_FIXED, + LENGTH_CONFIG_VARIABLE, + LENGTH_CONFIG_INFINITE, +}; + struct __attribute__((packed)) CC1101State { // Byte array accessors for bulk SPI transfers uint8_t *regs() { return reinterpret_cast(this); } diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 12a69551f5..fcfafa0c1a 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -7,9 +7,11 @@ BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" CONF_COLOR_DEPTH = "color_depth" +CONF_CRC_ENABLE = "crc_enable" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" CONF_REQUEST_HEADERS = "request_headers" diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 1eb83b7a33..4641db6483 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -1,6 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID, TimePeriod @@ -14,7 +15,6 @@ CONF_SX126X_ID = "sx126x_id" CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_CODING_RATE = "coding_rate" -CONF_CRC_ENABLE = "crc_enable" CONF_CRC_INVERTED = "crc_inverted" CONF_CRC_SIZE = "crc_size" CONF_CRC_POLYNOMIAL = "crc_polynomial" @@ -23,7 +23,6 @@ CONF_DEVIATION = "deviation" CONF_DIO1_PIN = "dio1_pin" CONF_HW_VERSION = "hw_version" CONF_MODULATION = "modulation" -CONF_ON_PACKET = "on_packet" CONF_PA_POWER = "pa_power" CONF_PA_RAMP = "pa_ramp" CONF_PAYLOAD_LENGTH = "payload_length" diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 77cb61f7f8..b569a75972 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -1,6 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID @@ -16,11 +17,9 @@ CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_BITSYNC = "bitsync" CONF_CODING_RATE = "coding_rate" -CONF_CRC_ENABLE = "crc_enable" CONF_DEVIATION = "deviation" CONF_DIO0_PIN = "dio0_pin" CONF_MODULATION = "modulation" -CONF_ON_PACKET = "on_packet" CONF_PA_PIN = "pa_pin" CONF_PA_POWER = "pa_power" CONF_PA_RAMP = "pa_ramp" diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml index 9373ca43e1..93f03e582e 100644 --- a/tests/components/cc1101/common.yaml +++ b/tests/components/cc1101/common.yaml @@ -1,13 +1,26 @@ cc1101: id: transceiver cs_pin: ${cs_pin} + gdo0_pin: ${gdo0_pin} frequency: 433.92MHz if_frequency: 153kHz filter_bandwidth: 203kHz channel: 0 channel_spacing: 200kHz - symbol_rate: 5000 - modulation_type: ASK/OOK + symbol_rate: 4800 + modulation_type: GFSK + packet_mode: true + packet_length: 8 + crc_enable: true + whitening: false + sync_mode: "16/16" + sync0: 0x91 + sync1: 0xD3 + num_preamble: 2 + on_packet: + then: + - lambda: |- + ESP_LOGD("cc1101", "packet %s rssi %.1f dBm lqi %u", format_hex(x).c_str(), rssi, lqi); button: - platform: template @@ -18,3 +31,7 @@ button: - cc1101.begin_rx: transceiver - cc1101.set_idle: transceiver - cc1101.reset: transceiver + - cc1101.send_packet: + data: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef] + - cc1101.send_packet: !lambda |- + return {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; diff --git a/tests/components/cc1101/test.esp32-idf.yaml b/tests/components/cc1101/test.esp32-idf.yaml index e075629679..966f11bb64 100644 --- a/tests/components/cc1101/test.esp32-idf.yaml +++ b/tests/components/cc1101/test.esp32-idf.yaml @@ -1,8 +1,8 @@ substitutions: cs_pin: GPIO5 + gdo0_pin: GPIO4 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml - remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/cc1101/test.esp8266.yaml b/tests/components/cc1101/test.esp8266.yaml index 7900658bc1..6f0f078507 100644 --- a/tests/components/cc1101/test.esp8266.yaml +++ b/tests/components/cc1101/test.esp8266.yaml @@ -1,8 +1,8 @@ substitutions: cs_pin: GPIO5 + gdo0_pin: GPIO4 packages: spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml - remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml <<: !include common.yaml From 078afe965668356118b9bdc6eb864a156a78af40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:30:55 -0600 Subject: [PATCH 49/57] [dashboard] Add ESPHOME_TRUSTED_DOMAINS support to events WebSocket (#12479) --- esphome/dashboard/web_server.py | 32 ++++++----- tests/dashboard/test_web_server.py | 87 ++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 804a2b99af..f94d8eea22 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -164,8 +164,24 @@ def websocket_method(name): return wrap +class CheckOriginMixin: + """Mixin to handle WebSocket origin checks for reverse proxy setups.""" + + def check_origin(self, origin: str) -> bool: + if "ESPHOME_TRUSTED_DOMAINS" not in os.environ: + return super().check_origin(origin) + trusted_domains = [ + s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",") + ] + url = urlparse(origin) + if url.hostname in trusted_domains: + return True + _LOGGER.info("check_origin %s, domain is not trusted", origin) + return False + + @websocket_class -class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): +class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): """Base class for ESPHome websocket commands.""" def __init__( @@ -183,18 +199,6 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): # use Popen() with a reading thread instead self._use_popen = os.name == "nt" - def check_origin(self, origin): - if "ESPHOME_TRUSTED_DOMAINS" not in os.environ: - return super().check_origin(origin) - trusted_domains = [ - s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",") - ] - url = urlparse(origin) - if url.hostname in trusted_domains: - return True - _LOGGER.info("check_origin %s, domain is not trusted", origin) - return False - def open(self, *args: str, **kwargs: str) -> None: """Handle new WebSocket connection.""" # Ensure messages from the subprocess are sent immediately @@ -601,7 +605,7 @@ DASHBOARD_SUBSCRIBER = DashboardSubscriber() @websocket_class -class DashboardEventsWebSocket(tornado.websocket.WebSocketHandler): +class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): """WebSocket handler for real-time dashboard events.""" _event_listeners: list[Callable[[], None]] | None = None diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 385841b1c8..10ca6061e6 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1567,3 +1567,90 @@ async def test_dashboard_yaml_loading_with_packages_and_secrets( # If we get here, secret resolution worked! assert "esphome" in config assert config["esphome"]["name"] == "test-download-secrets" + + +@pytest.mark.asyncio +async def test_websocket_check_origin_default_same_origin( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket uses default same-origin check when ESPHOME_TRUSTED_DOMAINS not set.""" + # Ensure ESPHOME_TRUSTED_DOMAINS is not set + env = os.environ.copy() + env.pop("ESPHOME_TRUSTED_DOMAINS", None) + with patch.dict(os.environ, env, clear=True): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + # Same origin should work (default Tornado behavior) + request = HTTPRequest( + url, headers={"Origin": f"http://127.0.0.1:{dashboard.port}"} + ) + ws = await websocket_connect(request) + try: + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + finally: + ws.close() + + +@pytest.mark.asyncio +async def test_websocket_check_origin_trusted_domain( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket accepts connections from trusted domains.""" + with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + request = HTTPRequest(url, headers={"Origin": "https://trusted.example.com"}) + ws = await websocket_connect(request) + try: + # Should receive initial state + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + finally: + ws.close() + + +@pytest.mark.asyncio +async def test_websocket_check_origin_untrusted_domain( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket rejects connections from untrusted domains.""" + with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + request = HTTPRequest(url, headers={"Origin": "https://untrusted.example.com"}) + with pytest.raises(HTTPClientError) as exc_info: + await websocket_connect(request) + # Should get HTTP 403 Forbidden due to origin check failure + assert exc_info.value.code == 403 + + +@pytest.mark.asyncio +async def test_websocket_check_origin_multiple_trusted_domains( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket accepts connections from multiple trusted domains.""" + with patch.dict( + os.environ, + {"ESPHOME_TRUSTED_DOMAINS": "first.example.com, second.example.com"}, + ): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + # Test second domain in list (with space after comma) + request = HTTPRequest(url, headers={"Origin": "https://second.example.com"}) + ws = await websocket_connect(request) + try: + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + finally: + ws.close() From 2e9ddd967c05608d7b6c910e04f6afd9f83e1969 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:31:07 -0600 Subject: [PATCH 50/57] [wifi_signal] Skip publishing disconnected RSSI value (#12482) --- esphome/components/wifi_signal/wifi_signal_sensor.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index 5d7f4b4562..9f581f1eb2 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -16,7 +16,12 @@ class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { #ifdef USE_WIFI_LISTENERS void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); } #endif - void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); } + void update() override { + int8_t rssi = wifi::global_wifi_component->wifi_rssi(); + if (rssi != wifi::WIFI_RSSI_DISCONNECTED) { + this->publish_state(rssi); + } + } void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } From c85b1b8609f1a7d4545b2ed40c74eb8dca7afacc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:31:19 -0600 Subject: [PATCH 51/57] [web_server_idf] Always enable LRU purge to prevent socket exhaustion (#12481) --- .../captive_portal/captive_portal.cpp | 6 ------ .../captive_portal/captive_portal.h | 4 ---- .../web_server_idf/web_server_idf.cpp | 19 +++++-------------- .../web_server_idf/web_server_idf.h | 2 -- 4 files changed, 5 insertions(+), 26 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 4eb00835b1..e1f92d2d2b 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -65,12 +65,6 @@ void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { this->base_->add_handler(this); -#ifdef USE_ESP32 - // Enable LRU socket purging to handle captive portal detection probe bursts - // OS captive portal detection makes many simultaneous HTTP requests which can - // exhaust sockets. LRU purging automatically closes oldest idle connections. - this->base_->get_server()->set_lru_purge_enable(true); -#endif } network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index ae9b9dfba0..f48c286f0c 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -40,10 +40,6 @@ class CaptivePortal : public AsyncWebHandler, public Component { void end() { this->active_ = false; this->disable_loop(); // Stop processing DNS requests -#ifdef USE_ESP32 - // Disable LRU socket purging now that captive portal is done - this->base_->get_server()->set_lru_purge_enable(false); -#endif this->base_->deinit(); if (this->dns_server_ != nullptr) { this->dns_server_->stop(); diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index af99b85e53..8c3ad288c0 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -117,18 +117,6 @@ void AsyncWebServer::end() { } } -void AsyncWebServer::set_lru_purge_enable(bool enable) { - if (this->lru_purge_enable_ == enable) { - return; // No change needed - } - this->lru_purge_enable_ = enable; - // If server is already running, restart it with new config - if (this->server_) { - this->end(); - this->begin(); - } -} - void AsyncWebServer::begin() { if (this->server_) { this->end(); @@ -136,8 +124,11 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = this->port_; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; - // Enable LRU purging if requested (e.g., by captive portal to handle probe bursts) - config.lru_purge_enable = this->lru_purge_enable_; + // Always enable LRU purging to handle socket exhaustion gracefully. + // When max sockets is reached, the oldest connection is closed to make room for new ones. + // This prevents "httpd_accept_conn: error in accept (23)" errors. + // See: https://github.com/esphome/esphome/issues/12464 + config.lru_purge_enable = true; // Use custom close function that shuts down before closing to prevent lwIP race conditions config.close_fn = AsyncWebServer::safe_close_with_shutdown; if (httpd_start(&this->server_, &config) == ESP_OK) { diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index a139e9e4df..5f9f598388 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -199,13 +199,11 @@ class AsyncWebServer { return *handler; } - void set_lru_purge_enable(bool enable); httpd_handle_t get_server() { return this->server_; } protected: uint16_t port_{}; httpd_handle_t server_{}; - bool lru_purge_enable_{false}; static esp_err_t request_handler(httpd_req_t *r); static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; From 3a1be6822eedc17616be8c31efe4f6daa4c67ef7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 13:47:11 -0600 Subject: [PATCH 52/57] [ota] Match client timeout to device timeout to prevent premature failures (#12484) --- esphome/espota2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index c29506224c..6349ad0fa8 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -322,8 +322,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) - # Set higher timeout during upload - sock.settimeout(30.0) + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures + sock.settimeout(90.0) upload_size = len(upload_contents) upload_size_encoded = [ From 734710d22ac0243e54af296d8a4aae0b364382c3 Mon Sep 17 00:00:00 2001 From: guillempages Date: Sun, 14 Dec 2025 21:15:19 +0100 Subject: [PATCH 53/57] [core] Use Arduino string macros only on ESP8266 (#12471) --- esphome/core/progmem.h | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 67131fd113..f9508945e8 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -1,16 +1,16 @@ #pragma once // Platform-agnostic macros for PROGMEM string handling -// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM) // On ESP8266/Arduino: Use Arduino's F() macro for PROGMEM strings +// On other platforms: Use plain strings (no PROGMEM) -#ifdef USE_ESP32 -#define ESPHOME_F(string_literal) (string_literal) -#define ESPHOME_PGM_P const char * -#define ESPHOME_strncpy_P strncpy -#else -// ESP8266 and other Arduino platforms use Arduino macros +#ifdef USE_ESP8266 +// ESP8266 uses Arduino macros #define ESPHOME_F(string_literal) F(string_literal) #define ESPHOME_PGM_P PGM_P #define ESPHOME_strncpy_P strncpy_P +#else +#define ESPHOME_F(string_literal) (string_literal) +#define ESPHOME_PGM_P const char * +#define ESPHOME_strncpy_P strncpy #endif From fffa16e4d81ea0adc06c0e78873850215cb6a070 Mon Sep 17 00:00:00 2001 From: mbohdal Date: Sun, 14 Dec 2025 22:40:08 +0100 Subject: [PATCH 54/57] [ethernet] fix used pins validation in configuration of RMII pins (#12486) --- esphome/components/ethernet/__init__.py | 5 ++++- .../test-lan8720-with-expander.esp32-idf.yaml | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index b4b1fcd9f6..e1ed327fb9 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -434,10 +434,13 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: # Check all used pins against RMII reserved pins for pin_list in pins.PIN_SCHEMA_REGISTRY.pins_used.values(): - for pin_path, _, pin_config in pin_list: + for pin_path, pin_device, pin_config in pin_list: pin_num = pin_config.get(CONF_NUMBER) if pin_num not in rmii_pins: continue + # Skip if pin is not directly on ESP, but at some expander (device set to something else than 'None') + if pin_device is not None: + continue # Found a conflict - show helpful error message pin_function = rmii_pins[pin_num] component_path = ".".join(str(p) for p in pin_path) diff --git a/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml b/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml new file mode 100644 index 0000000000..09da8d90d9 --- /dev/null +++ b/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml @@ -0,0 +1,15 @@ +<<: !include common-lan8720.yaml + +sn74hc165: + - id: sn74hc165_hub + clock_pin: GPIO13 + data_pin: GPIO14 + load_pin: GPIO15 + sr_count: 3 + +binary_sensor: + - platform: gpio + pin: + sn74hc165: sn74hc165_hub + number: 19 + id: relay_2 From fa0f07bfe9212fb646b5cb52451d538ab29445e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Dec 2025 16:26:34 -0600 Subject: [PATCH 55/57] [wifi] Fix WiFi recovery after failed connection attempts (#12483) --- esphome/components/wifi/wifi_component.cpp | 26 ++++++++++++++++--- .../wifi/wifi_component_esp_idf.cpp | 1 + .../wifi/wifi_component_libretiny.cpp | 10 +++++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d46916bfd9..a5e8c4a59d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -205,6 +205,21 @@ static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; /// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000; +/// Timeout for WiFi scan operations +/// This is a fallback in case we don't receive a scan done callback from the WiFi driver. +/// Normal scans complete via callback; this only triggers if something goes wrong. +static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000; + +/// Timeout for WiFi connection attempts +/// This is a fallback in case we don't receive connection success/failure callbacks. +/// Some platforms (especially LibreTiny/Beken) can take 30-60 seconds to connect, +/// particularly with fast_connect enabled where no prior scan provides channel info. +/// Do not lower this value - connection failures are detected via callbacks, not timeout. +/// If this timeout fires prematurely while a connection is still in progress, it causes +/// cascading failures: the subsequent scan will also fail because the WiFi driver is +/// still busy with the previous connection attempt. +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000; + static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { case WiFiRetryPhase::INITIAL_CONNECT: @@ -1035,7 +1050,7 @@ __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) void WiFiComponent::check_scanning_finished() { if (!this->scan_done_) { - if (millis() - this->action_started_ > 30000) { + if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) { ESP_LOGE(TAG, "Scan timeout"); this->retry_connect(); } @@ -1184,8 +1199,9 @@ void WiFiComponent::check_connecting_finished() { } uint32_t now = millis(); - if (now - this->action_started_ > 30000) { - ESP_LOGW(TAG, "Connection timeout"); + if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Connection timeout, aborting connection attempt"); + this->wifi_disconnect_(); this->retry_connect(); return; } @@ -1405,6 +1421,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // without disrupting the captive portal/improv connection if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { this->restart_adapter(); + } else { + // Even when skipping full restart, disconnect to clear driver state + // Without this, platforms like LibreTiny may think we're still connecting + this->wifi_disconnect_(); } // Clear scan flag - we're starting a new retry cycle this->did_scan_this_cycle_ = false; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 1f4eb1e42c..4a3c40a119 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -720,6 +720,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); s_sta_started = false; + s_sta_connecting = false; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { const auto &it = data->data.sta_authmode_change; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 4fd64bdfa3..36003a6eb4 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -291,6 +291,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); + s_sta_connecting = false; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -322,7 +323,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ // wifi_sta_connect_status_() to return IDLE. The main loop then sees // "Unknown connection status 0" (wifi_component.cpp check_connecting_finished) // and calls retry_connect(), aborting a connection that may succeed moments later. - // Real connection failures will have ssid/bssid populated, or we'll hit the 30s timeout. + // Real connection failures will have ssid/bssid populated, or we'll hit the connection timeout. if (it.ssid_len == 0 && s_sta_connecting) { ESP_LOGV(TAG, "Ignoring disconnect event with empty ssid while connecting (reason=%s)", get_disconnect_reason_str(it.reason)); @@ -527,7 +528,12 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; } #endif // USE_WIFI_AP -bool WiFiComponent::wifi_disconnect_() { return WiFi.disconnect(); } +bool WiFiComponent::wifi_disconnect_() { + // Clear connecting flag first so disconnect events aren't ignored + // and wifi_sta_connect_status_() returns IDLE instead of CONNECTING + s_sta_connecting = false; + return WiFi.disconnect(); +} bssid_t WiFiComponent::wifi_bssid() { bssid_t bssid{}; From 3a101d8886ac204381a84ddb6ce91cd34d785382 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Dec 2025 18:17:00 -0500 Subject: [PATCH 56/57] Bump version to 2025.12.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 75c624bf2b..532e207788 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.12.0b2 +PROJECT_NUMBER = 2025.12.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 61bdc7df8d..916136a69c 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.12.0b2" +__version__ = "2025.12.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 450962850a04341e3b821549538dab1a262dc85d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:29:51 -0500 Subject: [PATCH 57/57] [remote_base] Fix crash when ABBWelcome action has no data field (#12493) Co-authored-by: Claude --- esphome/components/remote_base/abbwelcome_protocol.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 4b922eb2f1..b8d9293c11 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -232,10 +232,10 @@ template class ABBWelcomeAction : public RemoteTransmitterAction data.set_message_id(this->message_id_.value(x...)); data.auto_message_id = this->auto_message_id_.value(x...); std::vector data_vec; - if (this->len_ >= 0) { + if (this->len_ > 0) { // Static mode: copy from flash to vector data_vec.assign(this->data_.data, this->data_.data + this->len_); - } else { + } else if (this->len_ < 0) { // Template mode: call function data_vec = this->data_.func(x...); } @@ -245,7 +245,7 @@ template class ABBWelcomeAction : public RemoteTransmitterAction } protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length + ssize_t len_{0}; // <0 = template mode, >=0 = static mode with length union Data { std::vector (*func)(Ts...); // Function pointer (stateless lambdas) const uint8_t *data; // Pointer to static data in flash