From d9359a70c1ef82ab907aba6adad45a27cd5d5fab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 17:29:10 -0500 Subject: [PATCH 01/11] [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) --- tests/integration/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1bf799b658..483d5392af 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -60,7 +60,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env = os.environ.copy() env["PLATFORMIO_CORE_DIR"] = str(cache_dir) env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") - env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") + # libdeps is keyed only by env name (the device name), and fixtures share + # names; two xdist workers first-compiling the same name race pio pkg + # install in the same directory. Keep libdeps per worker. + worker = os.environ.get("PYTEST_XDIST_WORKER", "master") + env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker) # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" # Compile with THIS tree's esphome sources, not wherever the venv's editable From 828eac90f36ffe9dd1fa714f41b27377fda2cafd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:51:59 +1200 Subject: [PATCH 02/11] Bump version to 2026.8.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index c83d95d0ef..ed0670621d 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 = 2026.8.0b6 +PROJECT_NUMBER = 2026.8.0 # 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 2296f8c0b7..17ff1e17d9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b6" +__version__ = "2026.8.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ca97c86d6580746e12e071351bf3d219ef7de51f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:06:55 -0500 Subject: [PATCH 03/11] [ci] Install requirements_dev.txt when the venv cache misses (#18502) --- .github/actions/restore-python/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index daf041819c..fab6dc6ffb 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -49,7 +49,7 @@ runs: python -m venv venv source venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' @@ -58,5 +58,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . From b7d0b676fc0cd5f93a772f6f11d398a157ae612d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20H=C3=A4ll?= Date: Thu, 20 Aug 2026 06:31:27 +0200 Subject: [PATCH 04/11] [wifi] Take the lwIP core lock around sntp_servermode_dhcp() (#18511) --- esphome/components/wifi/wifi_component_esp_idf.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 245390b097..24cb060edb 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -580,7 +580,14 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, // the built-in SNTP client has a memory leak in certain situations. Disable this feature. // https://github.com/esphome/issues/issues/2299 - sntp_servermode_dhcp(false); + { +#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 + // sntp_servermode_dhcp() is an empty macro unless lwIP is built with + // DHCP-supplied NTP servers, so only that build needs the core lock. + LwIPLock lock; +#endif + sntp_servermode_dhcp(false); + } // No manual IP is set; use DHCP client if (dhcp_status != ESP_NETIF_DHCP_STARTED) { From 5e9de7c94bde17b782e643a1d12745f67e23f3d6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:00:44 -0500 Subject: [PATCH 05/11] Bump bundled esphome-device-builder to 1.12.1 (#18541) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 18f705b501..55aa0ac982 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.1 RUN \ platformio settings set enable_telemetry No \ From 132f494195869750e7ccf3ad2a66f58c0234da21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 06:59:57 -0500 Subject: [PATCH 06/11] [nrf52] Rebuild the Python env when its interpreter symlink dangles (#18540) --- esphome/components/nrf52/framework.py | 26 ++++--- tests/unit_tests/test_nrf52_framework.py | 90 +++++++++++++++++++++++- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 6b32fe1fea..d487820440 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -62,6 +62,22 @@ def get_sdk_nrf_tools_path() -> Path: return path.resolve() +def _needs_venv_rebuild( + env_python_path: Path, sentinel: Path, requirements_hash: str +) -> bool: + """True when a penv must be (re)built. + + Rebuild when the interpreter is not a regular file, which covers a + dangling symlink (a cached venv outliving a host interpreter upgrade) + and a corrupt restore, or when the sentinel is missing or stale. + """ + return ( + not env_python_path.is_file() + or not sentinel.exists() + or sentinel.read_text(encoding="utf-8") != requirements_hash + ) + + def _get_python_env_path(version: str) -> Path: return get_sdk_nrf_tools_path() / "penvs" / version @@ -198,10 +214,7 @@ def setup_platformio_python_env() -> None: + "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode() + f"python{sys.version_info.major}.{sys.version_info.minor}".encode() ).hexdigest() - if ( - not sentinel.exists() - or sentinel.read_text(encoding="utf-8") != requirements_hash - ): + if _needs_venv_rebuild(env_python_path, sentinel, requirements_hash): rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment") create_venv(penv_path, msg="PlatformIO toolchain") @@ -250,10 +263,7 @@ def check_and_install() -> None: env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() - install_venv = ( - not sentinel.exists() - or sentinel.read_text(encoding="utf-8") != requirements_hash - ) + install_venv = _needs_venv_rebuild(env_python_path, sentinel, requirements_hash) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 0a6bddc280..c2ee0c2a75 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -16,6 +16,7 @@ from esphome.components.nrf52.framework import ( _get_penv_site_packages, _get_platformio_penv_path, _get_toolchain_platform_info, + _needs_venv_rebuild, check_and_install, get_build_env, get_sdk_nrf_tools_path, @@ -123,10 +124,19 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _touch_penv_python(penv: Path) -> None: + """Create the interpreter file so the rebuild gate sees a live venv.""" + python = get_python_env_executable_path(penv, "python") + python.parent.mkdir(parents=True, exist_ok=True) + python.touch() + + def _mark_venv_ready(python_env: Path) -> None: - """Write the venv sentinel with the current requirements hash.""" + """Write the venv sentinel with the current requirements hash and a + present interpreter so the rebuild gate passes.""" requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() (python_env / ".ready").write_text(requirements_hash, encoding="utf-8") + _touch_penv_python(python_env) class TestCheckAndInstall: @@ -148,6 +158,23 @@ class TestCheckAndInstall: mock_nrf52_ops.download_from_mirrors.assert_not_called() mock_nrf52_ops.archive_extract_all.assert_not_called() + def test_missing_interpreter_rebuilds_venv( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A valid sentinel must not mask a missing interpreter (a cached venv + restored after a host interpreter upgrade).""" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() + (nrf52_dirs.python_env / ".ready").write_text( + requirements_hash, encoding="utf-8" + ) + # no interpreter on disk + + check_and_install() + + mock_nrf52_ops.create_venv.assert_called_once() + def test_fresh_install_runs_all_steps( self, nrf52_dirs: SimpleNamespace, @@ -348,6 +375,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) with patch.dict(os.environ): setup_platformio_python_env() @@ -392,6 +420,22 @@ class TestSetupPlatformioPythonEnv: assert not (platformio_penv_dir / ".ready").exists() + def test_missing_interpreter_reinstalls( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A valid sentinel must not mask a missing interpreter.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + # no interpreter on disk + + with patch.dict(os.environ): + setup_platformio_python_env() + + mock_nrf52_ops.create_venv.assert_called_once() + def test_repeated_calls_do_not_duplicate_env_entries( self, platformio_penv_dir: Path, @@ -401,6 +445,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) site_packages = str(_get_penv_site_packages(platformio_penv_dir)) bin_dir = str( get_python_env_executable_path(platformio_penv_dir, "python").parent @@ -422,6 +467,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) site_packages = str(_get_penv_site_packages(platformio_penv_dir)) with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}): @@ -531,3 +577,45 @@ def testget_tools_path_default_is_global_cache( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" ).resolve() assert get_sdk_nrf_tools_path() == expected + + +def test_needs_venv_rebuild_gates(tmp_path: Path) -> None: + """The shared penv gate rebuilds on any missing or stale piece.""" + penv = tmp_path / "penv" + penv.mkdir() + python = penv / "python" + sentinel = penv / ".ready" + good_hash = "abc123" + + # Nothing in place yet + assert _needs_venv_rebuild(python, sentinel, good_hash) + + python.write_text("") + # Interpreter present but no sentinel + assert _needs_venv_rebuild(python, sentinel, good_hash) + + sentinel.write_text(good_hash, encoding="utf-8") + # Everything in place + assert not _needs_venv_rebuild(python, sentinel, good_hash) + + # Stale requirements hash + assert _needs_venv_rebuild(python, sentinel, "otherhash") + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlink creation needs privileges on Windows" +) +def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> None: + """A cached venv restored after a host interpreter upgrade has a + bin/python symlink whose target is gone; the valid sentinel must not + mask it.""" + penv = tmp_path / "penv" + penv.mkdir() + python = penv / "python" + sentinel = penv / ".ready" + sentinel.write_text("abc123", encoding="utf-8") + python.symlink_to(tmp_path / "hostedtoolcache" / "3.12.14" / "python3") + assert python.is_symlink() + assert not python.exists() + + assert _needs_venv_rebuild(python, sentinel, "abc123") From aafeca585920d39990457e46fec68faf8d4ae2d8 Mon Sep 17 00:00:00 2001 From: Alar Aun Date: Thu, 20 Aug 2026 16:54:28 +0300 Subject: [PATCH 07/11] [modbus_controller] Brace single-statement log bodies to fix -Wempty-body (#18543) --- esphome/components/modbus_controller/modbus_controller.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 2c568938e4..515459f62a 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -200,8 +200,9 @@ void ModbusController::update_range_(ModbusCommandItem &cmd) { return; } // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. - if (!cmd.send()) + if (!cmd.send()) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address()); + } } void ModbusController::update() { @@ -214,8 +215,9 @@ void ModbusController::update() { ESP_LOGV(TAG, "Module offline - retrying"); this->cmd_non_responses_ = 0; // allow the probe through can_send() for (auto &cmd : this->polling_command_items_) { - if (!cmd.send()) + if (!cmd.send()) { ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address()); + } } } else { ESP_LOGV(TAG, "Module offline - skipping update"); From 3c47ab42d63b53026dcfa611baca05d08b79e3ea Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:55:15 +1200 Subject: [PATCH 08/11] [core] Add type annotations to component Python (1/11) (#18338) --- esphome/components/bl0906/sensor.py | 12 +++++-- esphome/components/datetime/__init__.py | 36 +++++++++++++------ esphome/components/esp32_ble/__init__.py | 30 ++++++++++++---- esphome/components/esp32_rmt/__init__.py | 14 +++++--- esphome/components/espnow/__init__.py | 27 ++++++++------ .../espnow/packet_transport/__init__.py | 3 +- esphome/components/http_request/__init__.py | 20 +++++++---- .../components/http_request/ota/__init__.py | 13 +++++-- .../http_request/update/__init__.py | 3 +- esphome/components/i2s_audio/__init__.py | 13 +++---- .../i2s_audio/microphone/__init__.py | 13 +++---- .../components/i2s_audio/speaker/__init__.py | 13 +++---- esphome/components/mcp23xxx_base/__init__.py | 8 +++-- esphome/components/mcp4461/__init__.py | 3 +- esphome/components/mcp4461/output/__init__.py | 28 ++++++++++++--- esphome/components/microphone/__init__.py | 31 ++++++++++------ esphome/components/pn532/__init__.py | 14 ++++++-- esphome/components/pn532/binary_sensor.py | 7 ++-- esphome/components/pn7150/__init__.py | 26 +++++++++++--- esphome/components/pn7160/__init__.py | 26 +++++++++++--- 20 files changed, 245 insertions(+), 95 deletions(-) diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 059e10e962..1a0c2287ab 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -32,6 +32,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType # Import ICONS not included in esphome's const.py, from the local components const.py from .const import ICON_ENERGY, ICON_FREQUENCY, ICON_VOLTAGE @@ -145,13 +148,18 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 87997daa3d..f8b6446006 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -21,13 +21,14 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_YEAR, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -65,7 +66,7 @@ DATETIME_MODES = [ ] -def _validate_time_present(config): +def _validate_time_present(config: ConfigType) -> ConfigType: config = config.copy() if CONF_ON_TIME in config and CONF_TIME_ID not in config: time_id = cv.use_id(time.RealTimeClock)(None) @@ -139,7 +140,7 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema: @setup_entity("datetime") -async def setup_datetime_core_(var, config): +async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) @@ -160,7 +161,7 @@ async def setup_datetime_core_(var, config): await cg.register_parented(trigger, var) -async def register_datetime(var, config): +async def register_datetime(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) entity_type = config[CONF_TYPE].lower() @@ -169,14 +170,14 @@ async def register_datetime(var, config): await setup_datetime_core_(var, config) -async def new_datetime(config, *args): +async def new_datetime(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_datetime(var, config) return var @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(datetime_ns.using) @@ -193,7 +194,12 @@ async def to_code(config): ), synchronous=True, ) -async def datetime_date_set_to_code(config, action_id, template_arg, args): +async def datetime_date_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) @@ -226,7 +232,12 @@ async def datetime_date_set_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def datetime_time_set_to_code(config, action_id, template_arg, args): +async def datetime_time_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) @@ -259,7 +270,12 @@ async def datetime_time_set_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def datetime_datetime_set_to_code(config, action_id, template_arg, args): +async def datetime_datetime_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index f099c68e57..79747c6f31 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -31,7 +31,8 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, TimePeriod +from esphome.core import CORE, ID, TimePeriod +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -383,7 +384,7 @@ def _validate_key_sizes(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All(CONFIG_SCHEMA, _validate_key_sizes) -def validate_variant(_): +def validate_variant(_: ConfigType) -> None: variant = get_esp32_variant() if variant in NO_BLUETOOTH_VARIANTS: raise cv.Invalid(f"{variant} does not support Bluetooth") @@ -443,7 +444,7 @@ def validate_connection_slots(max_connections: int) -> None: ) -def final_validation(config) -> None: +def final_validation(config: ConfigType) -> None: validate_variant(config) if (name := config.get(CONF_NAME)) is not None: full_config = fv.full_config.get() @@ -518,7 +519,7 @@ def final_validation(config) -> None: FINAL_VALIDATE_SCHEMA = final_validation -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) cg.add(var.set_io_capability(config[CONF_IO_CAPABILITY])) @@ -605,19 +606,34 @@ async def to_code(config): @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) -async def ble_enabled_to_code(config, condition_id, template_arg, args): +async def ble_enabled_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(condition_id, template_arg) @automation.register_action( "ble.enable", BLEEnableAction, cv.Schema({}), synchronous=True ) -async def ble_enable_to_code(config, action_id, template_arg, args): +async def ble_enable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(action_id, template_arg) @automation.register_action( "ble.disable", BLEDisableAction, cv.Schema({}), synchronous=True ) -async def ble_disable_to_code(config, action_id, template_arg, args): +async def ble_disable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/esp32_rmt/__init__.py b/esphome/components/esp32_rmt/__init__.py index 1076bcabdc..a213a78778 100644 --- a/esphome/components/esp32_rmt/__init__.py +++ b/esphome/components/esp32_rmt/__init__.py @@ -1,17 +1,23 @@ +from collections.abc import Callable, Iterable +from typing import Any + from esphome.components import esp32 import esphome.config_validation as cv from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] VARIANTS_NO_RMT = {esp32.VARIANT_ESP32C2, esp32.VARIANT_ESP32C61} -def validate_rmt_not_supported(rmt_only_keys): +def validate_rmt_not_supported( + rmt_only_keys: Iterable[str], +) -> Callable[[ConfigType], ConfigType]: """Validate that RMT-only config keys are not used on variants without RMT hardware.""" rmt_only_keys = set(rmt_only_keys) - def _validator(config): + def _validator(config: ConfigType) -> ConfigType: if CORE.is_esp32: variant = esp32.get_esp32_variant() if variant in VARIANTS_NO_RMT: @@ -26,8 +32,8 @@ def validate_rmt_not_supported(rmt_only_keys): return _validator -def validate_clock_resolution(): - def _validator(value): +def validate_clock_resolution() -> Callable[[Any], int]: + def _validator(value: Any) -> int: cv.only_on_esp32(value) value = cv.int_(value) variant = esp32.get_esp32_variant() diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 373ef345d1..ee3732c406 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi @@ -14,6 +16,7 @@ from esphome.const import ( CONF_WIFI, ) from esphome.core import CORE, HexInt +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -78,7 +81,7 @@ CONF_CONTINUE_ON_ERROR = "continue_on_error" CONF_WAIT_FOR_SENT = "wait_for_sent" -def _validate_max_payload_size(value: int) -> int: +def _validate_max_payload_size(value: Any) -> int: if value > ESPNOW_PAYLOAD_V1: return cv.require_framework_version( esp_idf=cv.Version(5, 4, 0), @@ -88,7 +91,7 @@ def _validate_max_payload_size(value: int) -> int: return value -def validate_channel(value): +def validate_channel(value: Any) -> int: if value is None: raise cv.Invalid("channel is required if wifi is not configured") return wifi.validate_channel(value) @@ -129,7 +132,7 @@ CONFIG_SCHEMA = cv.All( ) -async def _trigger_to_code(config): +async def _trigger_to_code(config: ConfigType) -> MockObj: if address := config.get(CONF_ADDRESS): address = address.parts trigger = cg.new_Pvariable(config[CONF_TRIGGER_ID], address) @@ -145,7 +148,7 @@ async def _trigger_to_code(config): return trigger -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -180,13 +183,13 @@ async def to_code(config): # ========================================== A C T I O N S ================================================ -def validate_peer(value): +def validate_peer(value: Any) -> Any: if isinstance(value, cv.Lambda): return cv.returning_lambda(value) return cv.mac_address(value) -def _validate_raw_data(value): +def _validate_raw_data(value: Any) -> str | list: if isinstance(value, str): if len(value) > MAX_ESPNOW_PACKET_SIZE: raise cv.Invalid( @@ -204,7 +207,9 @@ def _validate_raw_data(value): ) -async def register_peer(var, config, args): +async def register_peer( + var: MockObj, config: ConfigType, args: TemplateArgsType +) -> None: peer = config[CONF_ADDRESS] if isinstance(peer, core.MACAddress): peer = [HexInt(p) for p in peer.parts] @@ -231,7 +236,7 @@ SEND_SCHEMA = PEER_SCHEMA.extend( ) -def _validate_send_action(config): +def _validate_send_action(config: ConfigType) -> ConfigType: if not config[CONF_WAIT_FOR_SENT] and not config[CONF_CONTINUE_ON_ERROR]: raise cv.Invalid( f"'{CONF_CONTINUE_ON_ERROR}' cannot be false if '{CONF_WAIT_FOR_SENT}' is false as the automation will not wait for the failed result.", @@ -267,7 +272,7 @@ async def send_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -316,7 +321,7 @@ async def peer_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) await register_peer(var, config, args) @@ -341,7 +346,7 @@ async def channel_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) diff --git a/esphome/components/espnow/packet_transport/__init__.py b/esphome/components/espnow/packet_transport/__init__.py index e6d66440db..ee4706ca1c 100644 --- a/esphome/components/espnow/packet_transport/__init__.py +++ b/esphome/components/espnow/packet_transport/__init__.py @@ -9,6 +9,7 @@ from esphome.components.packet_transport import ( import esphome.config_validation as cv from esphome.core import HexInt from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import ESPNowComponent, espnow_ns @@ -28,7 +29,7 @@ CONFIG_SCHEMA = transport_schema(ESPNowTransport).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: """Set up the ESP-NOW transport component.""" var, _ = await new_packet_transport(config) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 54d7f5c77b..afc39e06a8 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Any from esphome import automation import esphome.codegen as cg @@ -20,8 +21,10 @@ from esphome.const import ( PlatformFramework, __version__, ) -from esphome.core import CORE, Lambda +from esphome.core import CORE, ID, Lambda +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.helpers import IS_MACOS +from esphome.types import ConfigType DEPENDENCIES = ["network"] AUTO_LOAD = ["json", "watchdog"] @@ -63,14 +66,14 @@ CONF_BODY = "body" CONF_JSON = "json" -def validate_url(value): +def validate_url(value: Any) -> str: value = cv.url(value) if value.startswith(("http://", "https://")): return value raise cv.Invalid("URL must start with 'http://' or 'https://'") -def validate_ssl_verification(config): +def validate_ssl_verification(config: ConfigType) -> ConfigType: error_message = "" if CORE.is_rp2 and config[CONF_VERIFY_SSL]: @@ -91,7 +94,7 @@ def validate_ssl_verification(config): return config -def _declare_request_class(value): +def _declare_request_class(value: Any) -> ID: if CORE.is_host: return cv.declare_id(HttpRequestHost)(value) if CORE.is_esp32: @@ -151,7 +154,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_useragent(config[CONF_USERAGENT])) @@ -298,7 +301,12 @@ HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend( HTTP_REQUEST_SEND_ACTION_SCHEMA, synchronous=True, ) -async def http_request_action_to_code(config, action_id, template_arg, args): +async def http_request_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index b7026e0f55..784e4ee47a 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -3,8 +3,10 @@ import esphome.codegen as cg from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME -from esphome.core import coroutine_with_priority +from esphome.core import ID, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns @@ -42,7 +44,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.OTA_UPDATES) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) await cg.register_component(var, config) @@ -72,7 +74,12 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA, synchronous=True, ) -async def ota_http_request_action_to_code(config, action_id, template_arg, args): +async def ota_http_request_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index d84d80109a..4bdc30e4cf 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ota, update import esphome.config_validation as cv from esphome.const import CONF_SOURCE +from esphome.types import ConfigType from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns from ..ota import OtaHttpRequestComponent @@ -29,7 +30,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await update.new_update(config) ota_parent = await cg.get_variable(config[CONF_OTA_ID]) cg.add(var.set_ota_parent(ota_parent)) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 4809bf5a92..c5e82beb46 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -21,8 +21,9 @@ from esphome.components.esp32.const import ( import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE from esphome.core import CORE -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -145,7 +146,7 @@ I2S_MCLK_MULTIPLE = { _validate_bits = cv.float_with_unit("bits", "bit") -def validate_mclk_divisible_by_3(config): +def validate_mclk_divisible_by_3(config: ConfigType) -> ConfigType: if config[CONF_BITS_PER_SAMPLE] == 24 and config[CONF_MCLK_MULTIPLE] % 3 != 0: raise cv.Invalid( f"{CONF_MCLK_MULTIPLE} must be divisible by 3 when bits per sample is 24" @@ -159,7 +160,7 @@ def i2s_audio_component_schema( default_sample_rate: int, default_channel: str, default_bits_per_sample: str, -): +) -> cv.Schema: return cv.Schema( { cv.GenerateID(): cv.declare_id(class_), @@ -182,7 +183,7 @@ def i2s_audio_component_schema( ) -async def register_i2s_audio_component(var, config): +async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None: await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) slot_mode = config[CONF_CHANNEL] @@ -260,7 +261,7 @@ def _assign_ports() -> None: next_port += 1 -def _final_validate(_): +def _final_validate(_: ConfigType) -> None: i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] variant = get_esp32_variant() if variant not in I2S_PORTS: @@ -275,7 +276,7 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 9c6228087c..c217317237 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_NUM_CHANNELS, CONF_SAMPLE_RATE, ) +from esphome.types import ConfigType from .. import ( CONF_ADC_TYPE, @@ -46,7 +47,7 @@ I2S_PDM_DSR = { } -def _validate_esp32_variant(config): +def _validate_esp32_variant(config: ConfigType) -> ConfigType: variant = esp32.get_esp32_variant() if config[CONF_ADC_TYPE] == "external": if config[CONF_PDM] and variant not in PDM_VARIANTS: @@ -65,13 +66,13 @@ def _validate_esp32_variant(config): raise NotImplementedError -def _validate_channel(config): +def _validate_channel(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] == CONF_MONO: raise cv.Invalid(f"I2S microphone does not support {CONF_MONO}.") return config -def _set_num_channels_from_config(config): +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] in (CONF_LEFT, CONF_RIGHT): config[CONF_NUM_CHANNELS] = 1 else: @@ -80,7 +81,7 @@ def _set_num_channels_from_config(config): return config -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: audio.set_stream_limits( min_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), @@ -134,7 +135,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if config[CONF_ADC_TYPE] == "internal": raise cv.Invalid( "Internal ADC is no longer supported. Use an external I2S microphone instead." @@ -144,7 +145,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_i2s_audio_component(var, config) diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 6d3c39c68e..1849c376aa 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TIMEOUT, ) +from esphome.types import ConfigType from .. import ( CONF_I2S_DOUT_PIN, @@ -78,7 +79,7 @@ I2C_COMM_FMT_OPTIONS = { INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] -def _set_num_channels_from_config(config): +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] in (CONF_MONO, CONF_LEFT, CONF_RIGHT): config[CONF_NUM_CHANNELS] = 1 else: @@ -87,7 +88,7 @@ def _set_num_channels_from_config(config): return config -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: if config.get(CONF_SPDIF_MODE, False): # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( @@ -133,14 +134,14 @@ def _set_stream_limits(config): return config -def _select_speaker_class(config): +def _select_speaker_class(config: ConfigType) -> ConfigType: """Override ID type when SPDIF mode is enabled.""" if config.get(CONF_SPDIF_MODE, False): config[CONF_ID].type = I2SAudioSpeakerSPDIF return config -def _validate_esp32_variant(config): +def _validate_esp32_variant(config: ConfigType) -> ConfigType: variant = esp32.get_esp32_variant() if config[CONF_DAC_TYPE] == "internal": if variant not in INTERNAL_DAC_VARIANTS: @@ -207,7 +208,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if config[CONF_DAC_TYPE] == "internal": raise cv.Invalid( "Internal DAC is no longer supported. Use an external I2S DAC instead." @@ -238,7 +239,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_i2s_audio_component(var, config) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index d53499a78f..755d86e4ea 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE, ID, coroutine +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] CODEOWNERS = ["@jesserockz"] @@ -41,7 +43,7 @@ MCP23XXX_CONFIG_SCHEMA = cv.Schema( @coroutine -async def register_mcp23xxx(config, num_pins): +async def register_mcp23xxx(config: ConfigType, num_pins: int) -> MockObj: id: ID = config[CONF_ID] var = cg.new_Pvariable(id) await cg.register_component(var, config) @@ -52,7 +54,7 @@ async def register_mcp23xxx(config, num_pins): return var -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -81,7 +83,7 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23XXX, MCP23XXX_PIN_SCHEMA) -async def mcp23xxx_pin_to_code(config): +async def mcp23xxx_pin_to_code(config: ConfigType) -> MockObj: parent_id: ID = config[CONF_MCP23XXX] parent = await cg.get_variable(parent_id) diff --git a/esphome/components/mcp4461/__init__.py b/esphome/components/mcp4461/__init__.py index f3ef6f4917..60cece67d7 100644 --- a/esphome/components/mcp4461/__init__.py +++ b/esphome/components/mcp4461/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@p1ngb4ck"] DEPENDENCIES = ["i2c"] @@ -30,7 +31,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_DISABLE_WIPER_0], diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 99d4988c90..db1a1e6a29 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_INITIAL_VALUE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_MCP4461_ID, Mcp4461Component, mcp4461_ns @@ -34,7 +37,7 @@ CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay" VOLATILE_CHANNELS = ("A", "B", "C", "D") -def _validate_nonvolatile(config) -> None: +def _validate_nonvolatile(config: ConfigType) -> None: channel = str(config[CONF_CHANNEL]) # Channels E-H address the nonvolatile registers directly — the mirroring options only @@ -89,7 +92,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( FINAL_VALIDATE_SCHEMA = _validate_nonvolatile -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MCP4461_ID]) var = cg.new_Pvariable( config[CONF_ID], @@ -147,7 +150,12 @@ TERMINAL_ACTION_SCHEMA = cv.Schema( @automation.register_action( "mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True ) -async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_step_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, wiper) @@ -158,7 +166,12 @@ async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args): WIPER_ACTION_SCHEMA, synchronous=True, ) -async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_store_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, wiper) @@ -169,7 +182,12 @@ async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args): TERMINAL_ACTION_SCHEMA, synchronous=True, ) -async def mcp4461_wiper_terminal_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_terminal_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable( action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE] diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 6b5ee8c3e1..9a3f5b43e7 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -12,8 +14,10 @@ from esphome.const import ( CONF_ON_DATA, CONF_TRIGGER_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.coroutine import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] @@ -50,7 +54,7 @@ IsCapturingCondition = microphone_ns.class_( IsMutedCondition = microphone_ns.class_("IsMutedCondition", automation.Condition) -async def setup_microphone_core_(var, config): +async def setup_microphone_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_DATA, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation( @@ -60,7 +64,7 @@ async def setup_microphone_core_(var, config): ) -async def register_microphone(var, config): +async def register_microphone(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) await setup_microphone_core_(var, config) @@ -85,7 +89,7 @@ def microphone_source_schema( max_bits_per_sample: int = 16, min_channels: int = 1, max_channels: int = 1, -): +) -> cv.All: """Schema for a microphone source Components requesting microphone data should use this schema instead of accessing a microphone directly. @@ -97,7 +101,7 @@ def microphone_source_schema( max_channels (int, optional): Maximum number of channels the requesting component supports. Defaults to 1. """ - def _validate_unique_channels(config): + def _validate_unique_channels(config: list[int]) -> list[int]: if len(config) != len(set(config)): raise cv.Invalid("Channels must be unique") return config @@ -124,7 +128,7 @@ def microphone_source_schema( def final_validate_microphone_source_schema( component_name: str, sample_rate: int = cv.UNDEFINED -): +) -> Callable[[ConfigType], ConfigType]: """Validates that the microphone source can provide audio in the correct format. In particular it validates the sample rate and the enabled channels. Note that: @@ -136,7 +140,7 @@ def final_validate_microphone_source_schema( sample_rate (int, optional): The sample rate the component requesting mic audio requires """ - def _validate_audio_compatability(config): + def _validate_audio_compatability(config: ConfigType) -> ConfigType: if sample_rate is not cv.UNDEFINED: # Issues require changing the microphone configuration # - Verifies sample rates match @@ -161,7 +165,9 @@ def final_validate_microphone_source_schema( return _validate_audio_compatability -async def microphone_source_to_code(config, passive=False): +async def microphone_source_to_code( + config: ConfigType, passive: bool = False +) -> MockObj: """Creates a MicrophoneSource variable for codegen. Setting passive to true makes the MicrophoneSource never start/stop the microphone, but only receives audio when another component has actively started the Microphone. If false, then the microphone needs to be explicitly started/stopped. @@ -183,7 +189,12 @@ async def microphone_source_to_code(config, passive=False): return mic_source -async def microphone_action(config, action_id, template_arg, args): +async def microphone_action( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -219,6 +230,6 @@ automation.register_condition( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(microphone_ns.using) cg.add_define("USE_MICROPHONE") diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index f34df21647..6258932312 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -9,6 +9,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@jesserockz"] AUTO_LOAD = ["binary_sensor", "nfc"] @@ -41,7 +44,7 @@ PN532_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("1s")) -def CONFIG_SCHEMA(conf): +def CONFIG_SCHEMA(conf: ConfigType) -> None: if conf: raise cv.Invalid( "This component has been moved in 1.16, please see the docs for updated " @@ -56,7 +59,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn532(var, config): +async def setup_pn532(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) for conf in config.get(CONF_ON_TAG, []): @@ -85,7 +88,12 @@ async def setup_pn532(var, config): } ), ) -async def pn532_is_writing_to_code(config, condition_id, template_arg, args): +async def pn532_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/pn532/binary_sensor.py b/esphome/components/pn532/binary_sensor.py index b9c3103c65..8f490ba7d0 100644 --- a/esphome/components/pn532/binary_sensor.py +++ b/esphome/components/pn532/binary_sensor.py @@ -1,15 +1,18 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_UID from esphome.core import HexInt +from esphome.types import ConfigType from . import CONF_PN532_ID, PN532, pn532_ns DEPENDENCIES = ["pn532"] -def validate_uid(value): +def validate_uid(value: Any) -> str: value = cv.string_strict(value) for x in value.split("-"): if len(x) != 2: @@ -39,7 +42,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(PN532BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_PN532_ID]) diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 9dd3e8c5b0..4638992abf 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -12,6 +12,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor", "nfc"] CODEOWNERS = ["@kbx81", "@jesserockz"] @@ -107,7 +110,12 @@ PN7150_SCHEMA = cv.Schema( SET_MESSAGE_ACTION_SCHEMA, synchronous=True, ) -async def pn7150_set_message_to_code(config, action_id, template_arg, args): +async def pn7150_set_message_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) @@ -158,7 +166,12 @@ async def pn7150_set_message_to_code(config, action_id, template_arg, args): SIMPLE_ACTION_SCHEMA, synchronous=True, ) -async def pn7150_simple_action_to_code(config, action_id, template_arg, args): +async def pn7150_simple_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -174,7 +187,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn7150(var, config): +async def setup_pn7150(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) @@ -216,7 +229,12 @@ async def setup_pn7150(var, config): } ), ) -async def pn7150_is_writing_to_code(config, condition_id, template_arg, args): +async def pn7150_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index ef14a29099..7f9f9172a1 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -12,6 +12,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor", "nfc"] CODEOWNERS = ["@kbx81", "@jesserockz"] @@ -111,7 +114,12 @@ PN7160_SCHEMA = cv.Schema( SET_MESSAGE_ACTION_SCHEMA, synchronous=True, ) -async def pn7160_set_message_to_code(config, action_id, template_arg, args): +async def pn7160_set_message_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) @@ -162,7 +170,12 @@ async def pn7160_set_message_to_code(config, action_id, template_arg, args): SIMPLE_ACTION_SCHEMA, synchronous=True, ) -async def pn7160_simple_action_to_code(config, action_id, template_arg, args): +async def pn7160_simple_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -178,7 +191,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn7160(var, config): +async def setup_pn7160(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) if dwl_req_pin_config := config.get(CONF_DWL_REQ_PIN): @@ -228,7 +241,12 @@ async def setup_pn7160(var, config): } ), ) -async def pn7160_is_writing_to_code(config, condition_id, template_arg, args): +async def pn7160_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var From 8ef0f38f4efff4974bb1e96a210e128d99feb2fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 08:59:30 -0500 Subject: [PATCH 09/11] [ethernet] Skip the custom W5500 SPI driver for other ethernet types (#18533) --- esphome/components/ethernet/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 5eda0fc12c..7686b64cb4 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -811,6 +811,10 @@ _platform_filter = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, }, + "w5500_custom_spi.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, } ) @@ -830,6 +834,11 @@ def _filter_source_files() -> list[str]: # to avoid shadowing. Native IDF builds always need the custom driver. if cv.Version(5, 4, 2) <= idf_version() < cv.Version(6, 0, 0): excluded.append("esp_eth_phy_jl1101.c") + # The custom W5500 SPI driver is fully #ifdef'd on USE_ESP32 and + # USE_ETHERNET_W5500 (the platform filter map above handles non-ESP32); + # skip it entirely for the other ethernet types. + if eth_type != "W5500": + excluded.append("w5500_custom_spi.cpp") return excluded From 2d62ea78d203727c0f20a824add2b78271f33d24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 09:00:11 -0500 Subject: [PATCH 10/11] [ota] Skip partition-access OTA sources when the feature is disabled (#18532) --- esphome/components/ota/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 2d4de52e8f..1e2ee947c1 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -182,4 +182,11 @@ def FILTER_SOURCE_FILES() -> list[str]: for define in CORE.defines ): files.append("ota_signature_esp_idf.cpp") + # ota_bootloader_esp_idf.cpp and ota_partitions_esp_idf.cpp are fully + # #ifdef'd on USE_OTA_PARTITIONS (set by the esphome OTA platform when + # allow_partition_access is enabled). Filter them out otherwise for the + # same reason as above. + if not any(define.name == "USE_OTA_PARTITIONS" for define in CORE.defines): + files.append("ota_bootloader_esp_idf.cpp") + files.append("ota_partitions_esp_idf.cpp") return files From a8e721abebdda3a42b1b6ecf391a90938b2187ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:03:49 -0500 Subject: [PATCH 11/11] [esp32] Apply IDF component exclusions to native toolchain builds (#18531) --- esphome/build_gen/espidf.py | 41 +++++++- esphome/components/esp32/__init__.py | 19 ++-- esphome/espidf/toolchain.py | 24 ++++- tests/unit_tests/build_gen/test_espidf.py | 115 +++++++++++++++++++--- tests/unit_tests/test_espidf_toolchain.py | 69 +++++++++++++ 5 files changed, 243 insertions(+), 25 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index cf476555e7..b65ce23307 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,12 @@ import json from pathlib import Path -from esphome.components.esp32 import get_esp32_variant, idf_version +from esphome.components.esp32 import ( + get_esp32_variant, + get_excluded_builtin_components, + get_managed_component_require_names, + idf_version, +) import esphome.config_validation as cv from esphome.core import CORE from esphome.framework_helpers import ( @@ -119,24 +124,40 @@ def get_project_cmakelists(minimal: bool = False) -> str: # runs as a separate CMake script invocation that doesn't load the # project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_ # MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty). - from esphome.components.esp32 import get_managed_component_require_names - managed_components_property = "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)" for name in get_managed_component_require_names() ) + # Components excluded from the build (DEFAULT_EXCLUDED_IDF_COMPONENTS + # minus per-component re-includes). project.cmake reads the plain + # EXCLUDE_COMPONENTS variable when seeding the component list, so this + # must be set before project(). Emitted on minimal writes too so the + # discovery reconfigure never registers the excluded components. + excluded_components = get_excluded_builtin_components() + exclude_components_var = ( + f'set(EXCLUDE_COMPONENTS "{";".join(excluded_components)}")' + if excluded_components + else "" + ) + # Built-in IDF components exposed via our own property (not IDF's # __COMPONENT_REQUIRES_COMMON, which would append them to every # component's REQUIRES including real IDF components). Referenced by # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped # on minimal writes because project_description.json may be stale. + # Excluded components are dropped here as well: a stale + # project_description.json from a build without exclusions may still + # list them, and requiring an excluded component pulls it back into + # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). builtin_components_property = ( "" if minimal else "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" - for name in sorted(get_available_components() or []) + for name in sorted( + set(get_available_components() or []).difference(excluded_components) + ) ) ) @@ -165,6 +186,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{exclude_components_var} + {cpp_standard_options} {cxx_compile_options} @@ -264,3 +287,13 @@ def write_project(minimal: bool = False) -> None: CORE.relative_src_path("CMakeLists.txt"), get_component_cmakelists(), ) + + # Snapshot the exclusion set so has_outdated_files() can trigger a + # discovery reconfigure when it changes. Excluded components never + # register in project_description.json, so re-including one (e.g. a + # config gains mqtt) requires a fresh discovery pass before the + # ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it. + write_file_if_changed( + CORE.relative_build_path("exclude_components.esphomeinternal"), + ";".join(get_excluded_builtin_components()), + ) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3065cdadad..d6e0890751 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -738,6 +738,16 @@ def include_builtin_idf_component(name: str) -> None: CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].discard(name) +def get_excluded_builtin_components() -> list[str]: + """Return the sorted built-in IDF components excluded from the build. + + Single accessor for both build writers: the PlatformIO path passes it as + ``-DEXCLUDE_COMPONENTS`` and the native ESP-IDF path emits it into the + generated CMakeLists. + """ + return sorted(CORE.data.get(KEY_ESP32, {}).get(KEY_EXCLUDE_COMPONENTS, ())) + + def _enable_arduino_library(name: str) -> None: """Enable an Arduino library that is disabled by default. @@ -2122,13 +2132,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _write_exclude_components() -> None: """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" - if KEY_ESP32 not in CORE.data: - return - excluded = CORE.data[KEY_ESP32].get(KEY_EXCLUDE_COMPONENTS) - if excluded: - exclude_list = ";".join(sorted(excluded)) + if excluded := get_excluded_builtin_components(): cg.add_platformio_option( - "board_build.cmake_extra_args", f"-DEXCLUDE_COMPONENTS={exclude_list}" + "board_build.cmake_extra_args", + f"-DEXCLUDE_COMPONENTS={';'.join(excluded)}", ) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index bb6452acf2..07ba03e2cf 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -273,6 +273,11 @@ def has_outdated_files(): happen without any sdkconfig impact, and ``_write_idf_component_yml`` already deletes ``dependencies.lock`` on a change but that signal gets lost as soon as the lock is missing. + - ``exclude_components.esphomeinternal`` -- the resolved + EXCLUDE_COMPONENTS set. Excluded components never register in + ``project_description.json``, so re-including one needs a fresh + discovery pass before it can appear in the builtin-components + property that ``src`` REQUIRES. We deliberately don't watch: - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and @@ -291,6 +296,9 @@ def has_outdated_files(): f"sdkconfig.{CORE.name}.esphomeinternal" ) idf_component_yml_path = CORE.relative_build_path("src/idf_component.yml") + exclude_components_path = CORE.relative_build_path( + "exclude_components.esphomeinternal" + ) dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") @@ -309,7 +317,11 @@ def has_outdated_files(): cmakecache_txt_mtime = cmakecache_txt_path.stat().st_mtime return any( f.stat().st_mtime > cmakecache_txt_mtime - for f in [sdkconfig_internal_path, idf_component_yml_path] + for f in [ + sdkconfig_internal_path, + idf_component_yml_path, + exclude_components_path, + ] if f.exists() ) @@ -386,6 +398,16 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) + # Restamp the reference file has_outdated_files() compares against. + # A reconfigure that only changes properties or plain variables + # (sdkconfig options, the exclusion set) does not rewrite + # CMakeCache.txt, so without this the watched inputs stay newer + # forever and every subsequent build repeats the discovery pass. + # Done after the full write so an interrupt cannot leave a minimal + # CMakeLists behind that is already marked fresh. + cmakecache = CORE.relative_build_path("build/CMakeCache.txt") + if cmakecache.is_file(): + os.utime(cmakecache) if CORE.testing_mode: # Reconfigure again so cmake is up to date with the full # component list before the build's idf.py invocation runs -- diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index f21549b48c..ec01000920 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -11,6 +11,7 @@ import pytest from esphome.components.esp32 import ( KEY_COMPONENTS, KEY_ESP32, + KEY_EXCLUDE_COMPONENTS, KEY_IDF_VERSION, KEY_PATH, KEY_REF, @@ -28,6 +29,7 @@ def _reset_core(tmp_path: Path) -> None: CORE.data.setdefault(KEY_CORE, {}) CORE.data[KEY_ESP32] = { KEY_COMPONENTS: {}, + KEY_EXCLUDE_COMPONENTS: set(), KEY_IDF_VERSION: cv.Version(5, 5, 4), } @@ -47,6 +49,17 @@ def _write_project_description(tmp_path: Path, components: dict[str, str]) -> No ) +def _render(minimal: bool = False) -> str: + """Render the top-level CMakeLists with the standard variant/name patches.""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + return get_project_cmakelists(minimal=minimal) + + def test_get_available_components_returns_none_without_build_path() -> None: """No build_path set yet: must not raise on Path(None).""" CORE.build_path = None @@ -88,13 +101,7 @@ def test_get_project_cmakelists_minimal_omits_builtin_components_property( first write before the discovery pass refreshes it).""" _write_project_description(tmp_path, {"esp_lcd": "/idf/components/esp_lcd"}) - with ( - patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), - patch.object(CORE, "name", "test"), - ): - from esphome.build_gen.espidf import get_project_cmakelists - - content = get_project_cmakelists(minimal=True) + content = _render(minimal=True) assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS" not in content @@ -115,13 +122,7 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( }, ) - with ( - patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), - patch.object(CORE, "name", "test"), - ): - from esphome.build_gen.espidf import get_project_cmakelists - - content = get_project_cmakelists(minimal=False) + content = _render() assert ( "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd APPEND)" @@ -136,6 +137,92 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None: + """Excluded components are passed to IDF via EXCLUDE_COMPONENTS and are + dropped from ESPHOME_PROJECT_BUILTIN_COMPONENTS even when a stale + project_description.json still lists them (requiring an excluded + component would pull it back into the build).""" + _write_project_description( + tmp_path, + { + "esp_lcd": "/idf/components/esp_lcd", + "freertos": "/idf/components/freertos", + "unity": "/idf/components/unity", + }, + ) + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + + content = _render() + + assert 'set(EXCLUDE_COMPONENTS "esp_lcd;unity")' in content + # Must be set before project() so project.cmake sees it. + assert content.index("set(EXCLUDE_COMPONENTS") < content.index("project(test)") + assert ( + "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS freertos APPEND)" + in content + ) + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS unity" not in content + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd" not in content + + +def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: + """The discovery (minimal) write also excludes components so they never + register in project_description.json.""" + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} + + content = _render(minimal=True) + + assert 'set(EXCLUDE_COMPONENTS "unity")' in content + + +def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None: + """No EXCLUDE_COMPONENTS line at all when nothing is excluded.""" + content = _render() + + assert "EXCLUDE_COMPONENTS" not in content + + +def test_include_builtin_idf_component_removes_exclusion() -> None: + """include_builtin_idf_component() drops a name from the exclusion set so + a component a config actually uses is not passed to EXCLUDE_COMPONENTS.""" + from esphome.components.esp32 import ( + exclude_builtin_idf_component, + get_excluded_builtin_components, + include_builtin_idf_component, + ) + + exclude_builtin_idf_component("esp_eth") + exclude_builtin_idf_component("unity") + include_builtin_idf_component("esp_eth") + + assert get_excluded_builtin_components() == ["unity"] + + content = _render() + + assert 'set(EXCLUDE_COMPONENTS "unity")' in content + assert "esp_eth" not in content + + +def test_write_project_writes_exclude_components_stamp(tmp_path: Path) -> None: + """write_project() snapshots the exclusion set; the toolchain watches the + stamp to trigger a discovery reconfigure when the set changes (excluded + components never register in project_description.json).""" + CORE.build_flags = set() + CORE.build_path = tmp_path + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import write_project + + write_project() + + stamp = tmp_path / "exclude_components.esphomeinternal" + assert stamp.read_text() == "esp_lcd;unity" + + def test_get_component_cmakelists_no_link_flags() -> None: """With no -Wl, flags the target_link_options block is emitted with an empty body.""" CORE.build_flags = set() diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 26d812af8b..2556397aef 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -100,6 +100,33 @@ def _setup_build(setup_core: Path) -> tuple[Path, Path]: return compile_commands, cache +def test_has_outdated_files_detects_exclusion_change(setup_core: Path) -> None: + """A newer exclude_components.esphomeinternal stamp forces a reconfigure + so components that leave the exclusion set get rediscovered.""" + CORE.build_path = setup_core + build = setup_core / "build" + (build / "config").mkdir(parents=True) + (build / "config" / "sdkconfig.h").write_text("") + cmakecache = build / "CMakeCache.txt" + cmakecache.write_text("") + (build / "build.ninja").write_text("") + + with patch.object(CORE, "name", "test"): + assert not toolchain.has_outdated_files() + + stamp = setup_core / "exclude_components.esphomeinternal" + stamp.write_text("unity") + os.utime(stamp, (cmakecache.stat().st_mtime + 10,) * 2) + + assert toolchain.has_outdated_files() + + # The flag must clear once the reference file is restamped (as + # run_compile does after a successful discovery reconfigure); + # otherwise every later build would repeat the discovery pass. + os.utime(cmakecache, (stamp.stat().st_mtime + 10,) * 2) + assert not toolchain.has_outdated_files() + + def test_get_idedata_returns_none_without_compile_commands(setup_core: Path) -> None: """No compile DB yet -> None (rather than an error).""" _setup_build(setup_core) @@ -373,6 +400,48 @@ def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None: assert "IDF_PY_BUILD_JOBS" not in env +def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> None: + """After a successful discovery reconfigure the reference CMakeCache.txt + is restamped; cmake does not rewrite it when only properties or plain + variables change, so the staleness flag would otherwise never clear.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + cmakecache = CORE.relative_build_path("build/CMakeCache.txt") + cmakecache.parent.mkdir(parents=True, exist_ok=True) + cmakecache.write_text("") + old = cmakecache.stat().st_mtime - 100 + os.utime(cmakecache, (old, old)) + + with ( + patch.object(toolchain, "need_reconfigure", return_value=True), + patch("esphome.build_gen.espidf.write_project"), + patch.object(toolchain, "run_reconfigure", return_value=0), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + assert cmakecache.stat().st_mtime > old + + +def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None: + """A discovery pass that produced no CMakeCache.txt (nothing to restamp) + still completes normally.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=True), + patch("esphome.build_gen.espidf.write_project"), + patch.object(toolchain, "run_reconfigure", return_value=0), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + assert not CORE.relative_build_path("build/CMakeCache.txt").exists() + + def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: """compile_process_limit is forwarded to run_idf_py as the job limit.""" _setup_build(setup_core)