From 370e8fffed7a72e1d804827bdf8bebea4d6ddfc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <3060199+jesserockz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:16:02 -0500 Subject: [PATCH] [core] Add the arduino toolchain seam and validate --toolchain on every platform (#18556) --- esphome/__main__.py | 17 +++--- esphome/compiled_config.py | 15 ++++++ esphome/components/esp32/__init__.py | 18 ++----- esphome/components/esp8266/__init__.py | 3 ++ esphome/components/host/__init__.py | 1 + esphome/components/libretiny/__init__.py | 3 +- esphome/components/nrf52/__init__.py | 11 ++-- esphome/components/rp2/__init__.py | 1 + esphome/config_validation.py | 62 ++++++++++++++++++++++ esphome/const.py | 8 +++ esphome/core/__init__.py | 16 ++++++ esphome/core/config.py | 50 +++++++++++++---- tests/component_tests/esp32/test_esp32.py | 14 +++++ tests/unit_tests/core/test_config.py | 49 +++++++++++++++++ tests/unit_tests/test_compiled_config.py | 31 +++++++++++ tests/unit_tests/test_config_validation.py | 45 ++++++++++++++++ tests/unit_tests/test_core.py | 18 +++++++ tests/unit_tests/test_main.py | 36 +++++++++++++ tests/unit_tests/test_nrf52_framework.py | 15 ++++-- 19 files changed, 370 insertions(+), 43 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0da86b3ec0..632d2ba3d0 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2734,10 +2734,14 @@ def run_esphome(argv): # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. config: ConfigType | None = None - cache_eligible = ( + cache_write_eligible = ( args.command in ("upload", "logs") and not command_line_substitutions ) - if cache_eligible: + # An explicit --toolchain must re-run the per-platform validators, so + # gate only the cache read; the refresh below saves the result unless + # the sidecar records a different toolchain. + cache_read_eligible = cache_write_eligible and args.toolchain is None + if cache_read_eligible: from esphome.compiled_config import load_compiled_config config = load_compiled_config(conf_path) @@ -2761,17 +2765,14 @@ def run_esphome(argv): return 2 CORE.config = config - # Fallback for platforms whose validators didn't set the toolchain - # (only the esp32 component reads esp32.framework.toolchain). All - # other platforms only support PlatformIO today. Must run before the - # cache refresh below so its sidecar records the same toolchain a - # compile would. + # The cache fast path skips validation, and legacy sidecars lack the + # toolchain field. Must run before the cache refresh below. if CORE.toolchain is None: CORE.toolchain = Toolchain.PLATFORMIO # Refresh the cache so the next upload/logs hits the fast path # instead of re-running read_config. - if cache_eligible and cache_missed: + if cache_write_eligible and cache_missed: from esphome.compiled_config import save_compiled_config_and_sidecar save_compiled_config_and_sidecar(config) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index be03eea965..0d855d71db 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -100,6 +100,21 @@ def _refresh_sidecar() -> bool: ) return False if old is not None and old.can_apply_to_core(): + if ( + old.toolchain is not None + and CORE.toolchain is not None + and old.toolchain != CORE.toolchain.value + ): + # Platforms normalize toolchain-sensitive keys differently; + # never cache a config validated under a different toolchain + # than the compile's + _LOGGER.debug( + "Not caching: config validated with toolchain %r but the " + "last compile used %r", + CORE.toolchain.value, + old.toolchain, + ) + return False # Compile-written; nothing to refresh. return True if CORE.build_path is not None and CORE.build_path.exists(): diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 073d87402a..bc91f29a42 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1105,19 +1105,11 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType: return config -def _validate_toolchain(value) -> Toolchain: - return Toolchain( - cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value) - ) - - -def _resolve_toolchain(value: ConfigType) -> ConfigType: - # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. - # Runs before _detect_variant so downstream validators can rely on - # CORE.toolchain instead of re-resolving it from the config dict. - if CORE.toolchain is None: - CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF) - return value +_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ESP_IDF) +_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) +# Runs before _detect_variant so downstream validators can rely on +# CORE.toolchain instead of re-resolving it from the config dict. +_resolve_toolchain = cv.resolve_toolchain("ESP32", _TOOLCHAINS, Toolchain.ESP_IDF) def _check_versions(config: ConfigType) -> ConfigType: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 75483c5293..6f29cd7774 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -247,6 +247,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean, } ), + # Until the native toolchain lands, PlatformIO is the only backend; + # reject a --toolchain this platform cannot serve yet. + cv.require_platformio_toolchain("ESP8266"), set_core_data, ) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index c5846f5406..401bba5118 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -37,6 +37,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address, } ), + cv.require_platformio_toolchain("host"), set_core_data, ) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index c56cc48055..50dc787799 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -300,7 +300,7 @@ FRAMEWORK_SCHEMA = cv.All( _check_debug_order, ) -CONFIG_SCHEMA = cv.All(_notify_old_style) +CONFIG_SCHEMA = cv.All(_notify_old_style, cv.require_platformio_toolchain("LibreTiny")) BASE_SCHEMA = cv.Schema( { @@ -314,6 +314,7 @@ BASE_SCHEMA = cv.Schema( ) BASE_SCHEMA.add_extra(_detect_variant) +BASE_SCHEMA.add_extra(cv.require_platformio_toolchain("LibreTiny")) BASE_SCHEMA.add_extra(_update_core_data) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 2d25558254..aeeaba0c11 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -125,10 +125,8 @@ def set_core_data(config: ConfigType) -> ConfigType: return config -def _resolve_toolchain(config: ConfigType) -> ConfigType: - if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF) - return config +_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF) +_resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF) def set_framework(config: ConfigType) -> ConfigType: @@ -170,10 +168,7 @@ BOOTLOADERS = [ ] -def _validate_toolchain(value) -> Toolchain: - return Toolchain( - cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value) - ) +_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) def _detect_bootloader(config: ConfigType) -> ConfigType: diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index ed975ec01a..dae7df26c3 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -312,6 +312,7 @@ CONFIG_SCHEMA = cv.All( ), cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT), _detect_variant, + cv.require_platformio_toolchain("RP2"), set_core_data, ) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index f455c7b8bf..98001d5d5b 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -53,6 +53,7 @@ from esphome.const import ( CONF_SETUP_PRIORITY, CONF_STATE_TOPIC, CONF_SUBSCRIBE_QOS, + CONF_TOOLCHAIN, CONF_TOPIC, CONF_TYPE, CONF_TYPE_ID, @@ -75,6 +76,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, Framework, + Toolchain, __version__ as ESPHOME_VERSION, ) from esphome.core import ( @@ -106,6 +108,9 @@ from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base +if typing.TYPE_CHECKING: + from esphome.types import ConfigType + _LOGGER = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -2532,6 +2537,63 @@ def platformio_version_constraint(value): return constraints +def _check_supported_toolchain( + platform_name: str, supported: tuple[Toolchain, ...] +) -> None: + """Raise when the resolved ``CORE.toolchain`` is not in ``supported`` + (one message shape for every platform).""" + toolchain = CORE.toolchain + if toolchain is None: + # A caller ran the check before resolving; an ordering bug, not a + # user error + raise Invalid(f"Toolchain was not resolved before {platform_name} validation") + if toolchain not in supported: + names = ", ".join(f"'{tc.value}'" for tc in supported) + raise Invalid( + f"Unsupported toolchain " + f"'{toolchain.value}' for " + f"{platform_name}. Supported: {names}." + ) + + +def toolchain_enum(supported: tuple[Toolchain, ...]) -> Callable[[str], Toolchain]: + """Schema validator for a platform's ``toolchain`` config key.""" + + def validator(value: str) -> Toolchain: + return Toolchain(one_of(*supported, lower=True)(value)) + + return validator + + +def resolve_toolchain( + platform_name: str, supported: tuple[Toolchain, ...], default: Toolchain +) -> Callable[[ConfigType], ConfigType]: + """Resolve ``CORE.toolchain`` (CLI > YAML > default) and reject one the + platform cannot serve. + + Add to the platform's validation chain before anything that reads + ``CORE.toolchain``. + """ + + def validator(config: ConfigType) -> ConfigType: + if CORE.toolchain is None: + CORE.toolchain = config.get(CONF_TOOLCHAIN, default) + _check_supported_toolchain(platform_name, supported) + return config + + return validator + + +def require_platformio_toolchain( + platform_name: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject a CLI-selected toolchain other than PlatformIO, for platforms + with only the PlatformIO backend.""" + return resolve_toolchain( + platform_name, (Toolchain.PLATFORMIO,), Toolchain.PLATFORMIO + ) + + def require_framework_version( *, max_version=False, diff --git a/esphome/const.py b/esphome/const.py index 0dd948544f..6f83f0c937 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -21,6 +21,14 @@ class Toolchain(StrEnum): PLATFORMIO = "platformio" ESP_IDF = "esp-idf" SDK_NRF = "sdk-nrf" + # ESP8266: the Arduino core built directly (no PlatformIO) + ARDUINO = "arduino" + + +# Toolchains that drive their build natively and never read platformio.ini. +# SDK_NRF is absent on purpose: the zephyr backend keeps consuming +# platformio_options. +NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO}) class Platform(StrEnum): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 0f1ac9213e..2ec2a08e83 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -21,6 +21,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + NATIVE_TOOLCHAINS, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -982,6 +983,19 @@ class EsphomeCore: def using_toolchain_sdk_nrf(self): return self.toolchain == Toolchain.SDK_NRF + @property + def using_toolchain_arduino(self): + """The native ESP8266 Arduino build toolchain (unlike + ``using_arduino``, which is the target framework).""" + return self.toolchain == Toolchain.ARDUINO + + @property + def using_native_toolchain(self): + """Whether the selected toolchain builds natively, without reading + ``platformio.ini`` (see ``NATIVE_TOOLCHAINS`` in ``esphome.const``; + keep its membership in sync with ``write_cpp_file``'s dispatch).""" + return self.toolchain in NATIVE_TOOLCHAINS + @property def using_zephyr(self): return self.target_framework == "zephyr" @@ -1095,6 +1109,8 @@ class EsphomeCore: return build_flag def add_build_unflag(self, build_unflag: str) -> None: + # No warning for using_toolchain_arduino: the native ESP8266 build + # honors build_unflags (token-level, matching PlatformIO). if self.using_toolchain_esp_idf: # The native ESP-IDF build generator does not consume build_unflags _LOGGER.warning( diff --git a/esphome/core/config.py b/esphome/core/config.py index 1095a4886e..472ca64c9a 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -555,12 +555,24 @@ def _add_library_str(lib: str) -> None: cg.add_library(lib, None) +# platformio_options keys the native ESP8266 Arduino generator (a later PR +# in this chain) will honor; its ignored-option warning will consume the same +# list so the two cannot drift +NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscript"}) +# The full set that survives into CORE.platformio_options under the native +# arduino toolchain: lib_ignore is the only specially-translated key below +# that is stored rather than translated away. Consumed by the esp8266 native +# backend (later in this chain) for its ignored-option warning; defined here +# so it stays adjacent to the routing. +NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"} + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: - if CORE.using_toolchain_esp_idf: - # The native ESP-IDF build doesn't read platformio.ini; honor the - # options with a native equivalent and warn about the rest, which - # would otherwise be silently ignored. + if CORE.using_native_toolchain: + # The native builds don't read platformio.ini; honor the options + # with a native equivalent and warn about the rest, which would + # otherwise be silently ignored. for key, val in pio_options.items(): vals = [val] if isinstance(val, str) else val if key == CONF_BUILD_FLAGS: @@ -573,23 +585,41 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No ) for flag in vals: cg.add_build_flag(flag) + elif key == "build_unflags": + # Native equivalent: add_build_unflag (honored token-level by + # the arduino generator; the IDF generator warns there) + for flag in vals: + CORE.add_build_unflag(flag) elif key == "lib_deps": - # Routed through the regular library mechanism so the libraries - # are converted to IDF components like any other PIO library + # Routed through the regular library mechanism so the + # libraries reach the native backend's converter (IDF + # components, or the ESP8266 native library resolution) for lib in vals: _add_library_str(lib) elif key == "lib_ignore": - # Read by the PIO-library-to-IDF-component conversion - # (generate_idf_components); filters both top-level libraries - # and dependencies discovered during conversion + # Read by the shared library conversion (lib_ignore_set in + # platformio/library.py); filters top-level libraries and + # discovered dependencies cg.add_platformio_option(key, vals) + elif ( + key in NATIVE_ARDUINO_PIO_OPTIONS + and CORE.using_toolchain_arduino + and vals + ): + # The esp8266 native generator reads these as scalars; the + # schema also permits the list form, where the last value + # wins like a later platformio.ini line (an empty list falls + # through to the ignored-option warning). Other native + # toolchains have no equivalent and fall through too. + cg.add_platformio_option(key, vals[-1]) elif key != "upload_speed": # upload_speed needs no handling: it is read from the raw # config at upload time (upload_using_esptool) _LOGGER.warning( "esphome->platformio_options->%s is ignored when building with " - "the native ESP-IDF toolchain", + "the native '%s' toolchain", key, + CORE.toolchain.value, ) return # Add includes at the very end, so that they override everything diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 0ffbe16a17..297844b4e6 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -132,6 +132,20 @@ def test_esp32_rejects_unsupported_toolchains( CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain}) +def test_esp32_rejects_unsupported_cli_toolchain( + set_core_config: SetCoreConfigCallable, +) -> None: + """A --toolchain the platform cannot serve fails instead of silently + building with PlatformIO (the CLI path bypasses the YAML validator).""" + set_core_config(PlatformFramework.ESP32_IDF) + + from esphome.components.esp32 import CONFIG_SCHEMA + + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"): + CONFIG_SCHEMA({"variant": VARIANT_ESP32}) + + @pytest.mark.parametrize( ("config", "error_match"), [ diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e09edd7f26..e620f8ec7f 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1285,6 +1285,7 @@ async def test_add_platformio_options_native_idf( await config._add_platformio_options( { "build_flags": "-DSINGLE_FLAG", # string and list forms both valid + "build_unflags": ["-Os"], "lib_deps": ["bblanchon/ArduinoJson@7.4.2"], "lib_ignore": "libsodium", "upload_speed": "115200", @@ -1294,6 +1295,7 @@ async def test_add_platformio_options_native_idf( assert "-DSINGLE_FLAG" in CORE.build_flags assert "ArduinoJson" in CORE.platformio_libraries + assert "-Os" in CORE.build_unflags # lib_ignore is stored (listified) for generate_idf_components to read; # nothing else lands in platformio_options on the native toolchain. assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} @@ -1389,3 +1391,50 @@ def test_esphome_build_internals_are_yaml_only() -> None: assert markers[field].visibility is cv.Visibility.ADVANCED, field # A regular device-config field stays on the main form. assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_arduino( + caplog: pytest.LogCaptureFixture, +) -> None: + """The native ESP8266 Arduino toolchain honors board_build.f_cpu (a + real-world overclock knob) and warns about the rest like native IDF.""" + CORE.toolchain = Toolchain.ARDUINO + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp8266", + KEY_TARGET_FRAMEWORK: "arduino", + } + + await config._add_platformio_options( + { + "board_build.f_cpu": "160000000L", + # The schema also permits the list form; the last value wins + # and reaches the generator as a scalar + "board_build.ldscript": ["eagle.flash.2m.ld", "eagle.flash.4m2m.ld"], + "board_build.filesystem": "littlefs", + "upload_speed": "115200", + } + ) + + assert CORE.platformio_options["board_build.f_cpu"] == "160000000L" + assert CORE.platformio_options["board_build.ldscript"] == "eagle.flash.4m2m.ld" + assert "board_build.f_cpu is ignored" not in caplog.text + assert "board_build.ldscript is ignored" not in caplog.text + assert ( + "esphome->platformio_options->board_build.filesystem is ignored" in caplog.text + ) + # An empty list for an honored key is not a scalar; it falls through + # to the ignored-option warning instead of an IndexError + await config._add_platformio_options({"board_build.ldscript": []}) + assert "board_build.ldscript is ignored" in caplog.text + assert "'arduino' toolchain" in caplog.text + assert "upload_speed" not in caplog.text + + +def test_esp8266_rejects_unsupported_cli_toolchain() -> None: + """Until the native backend lands, ESP8266 serves only PlatformIO.""" + from esphome.components.esp8266 import CONFIG_SCHEMA + + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"): + CONFIG_SCHEMA({"board": "nodemcuv2"}) diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 77690a6897..4333420a9e 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -68,6 +68,7 @@ def _write_storage( esp_platform: str | None = "ESP32", core_platform: str | None = "esp32", build_path: str | None = "/build/lite_test", + toolchain: str | None = None, ) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) @@ -88,6 +89,7 @@ def _write_storage( "no_mdns": False, "framework": "arduino", "core_platform": core_platform, + "toolchain": toolchain, } storage_path.write_text(json.dumps(data), encoding="utf-8") @@ -629,6 +631,35 @@ def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> assert load_compiled_config(yaml_path) is not None +@pytest.mark.parametrize( + ("sidecar_toolchain", "saved"), + [ + ("esp-idf", False), + ("platformio", True), + (None, True), # legacy sidecar without the field: guard is inert + ], +) +def test_save_compiled_config_and_sidecar_toolchain_mismatch( + tmp_path: Path, sidecar_toolchain: str | None, saved: bool +) -> None: + """A config validated under a different toolchain than the compile's + must not overwrite the cache.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}} + CORE.toolchain = Toolchain.PLATFORMIO + _write_storage( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json", + toolchain=sidecar_toolchain, + ) + + save_compiled_config_and_sidecar(CORE.config) + + cache = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.json" + assert cache.exists() is saved + assert (load_compiled_config(yaml_path) is not None) is saved + + @pytest.mark.parametrize("command", ["upload", "logs"]) def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( tmp_path: Path, command: str diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 971c4e462d..0f927a6513 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +import importlib import json import logging from pathlib import Path @@ -48,6 +49,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, Framework, + Toolchain, ) from esphome.core import ( CORE, @@ -3165,3 +3167,46 @@ def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None: with pytest.raises(Invalid, match="is not a file"): cv.file_("/original/config/headers") + + +def test_require_platformio_toolchain() -> None: + """Platforms with only the PlatformIO backend reject other toolchains.""" + validator = cv.require_platformio_toolchain("RP2") + CORE.toolchain = None + config: dict = {} + assert validator(config) is config + assert CORE.toolchain == Toolchain.PLATFORMIO + + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(Invalid, match="Unsupported toolchain 'arduino' for RP2"): + validator(config) + + +def test_check_supported_toolchain_unresolved_is_an_ordering_bug() -> None: + """Calling the check before resolution fails naming the ordering bug, + not a user-facing unsupported-toolchain error.""" + CORE.toolchain = None + with pytest.raises(Invalid, match="not resolved before RP2 validation"): + cv._check_supported_toolchain("RP2", (Toolchain.PLATFORMIO,)) + + +@pytest.mark.parametrize( + ("platform", "minimal_config"), + [ + ("host", {}), + ("rp2", {"board": "rpipicow"}), + ("bk72xx", {"board": "generic-bk7231n-qfn32-tuya"}), + ("rtl87xx", {"board": "generic-rtl8710bn-2mb-788k"}), + ("ln882x", {"board": "generic-ln882h"}), + # The legacy stub platform must reject too, not just the chip families + ("libretiny", {}), + ], +) +def test_every_platformio_only_platform_rejects_arduino_toolchain( + platform: str, minimal_config: dict +) -> None: + """A platform that cannot serve a CLI toolchain rejects it at validation.""" + module = importlib.import_module(f"esphome.components.{platform}") + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(Invalid, match="Unsupported toolchain 'arduino'"): + module.CONFIG_SCHEMA(dict(minimal_config)) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 7adf955217..0c96f8c8c9 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -958,6 +958,24 @@ class TestEsphomeCore: target.toolchain = const.Toolchain.ESP_IDF assert target.using_toolchain_sdk_nrf is False + def test_using_toolchain_arduino(self, target): + """A toolchain choice, distinct from the arduino target framework.""" + target.toolchain = const.Toolchain.ARDUINO + assert target.using_toolchain_arduino is True + target.toolchain = const.Toolchain.PLATFORMIO + assert target.using_toolchain_arduino is False + + def test_using_native_toolchain(self, target): + """True exactly for the toolchains that never read platformio.ini.""" + target.toolchain = const.Toolchain.ESP_IDF + assert target.using_native_toolchain is True + target.toolchain = const.Toolchain.ARDUINO + assert target.using_native_toolchain is True + target.toolchain = const.Toolchain.PLATFORMIO + assert target.using_native_toolchain is False + target.toolchain = const.Toolchain.SDK_NRF + assert target.using_native_toolchain is False + def test_add_library__extracts_short_name_from_path(self, target): """Test add_library extracts short name from library paths like owner/lib.""" target.data[const.KEY_CORE] = { diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index c7a5c85638..08c99e2119 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7195,6 +7195,42 @@ def test_compile_program_espidf_idedata_none_warns( assert "No idedata was generated" in caplog.text +def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None: + """An explicit --toolchain must run the per-platform validators, so the + upload/logs fast path becomes a cache miss.""" + conf = tmp_path / "device.yaml" + conf.write_text("esphome:\n name: t\n") + argv = ["esphome", "--toolchain", "arduino", "logs", str(conf)] + with ( + patch("esphome.compiled_config.load_compiled_config") as mock_cache, + patch("esphome.config.read_config", return_value=None) as mock_read, + ): + assert run_esphome(argv) == 2 + mock_cache.assert_not_called() + mock_read.assert_called_once() + + +def test_cli_toolchain_still_refreshes_the_validated_config_cache( + tmp_path: Path, +) -> None: + """An explicit --toolchain gates only the cache read; with a matching + sidecar the freshly validated config is still saved.""" + conf = tmp_path / "device.yaml" + conf.write_text("esphome:\n name: t\n") + argv = ["esphome", "--toolchain", "platformio", "logs", str(conf)] + with ( + patch("esphome.compiled_config.load_compiled_config") as mock_load, + patch("esphome.config.read_config", return_value={CONF_ESPHOME: {}}), + patch("esphome.compiled_config.save_compiled_config_and_sidecar") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", {"logs": Mock(return_value=0)} + ), + ): + assert run_esphome(argv) == 0 + mock_load.assert_not_called() + mock_save.assert_called_once() + + @pytest.mark.asyncio async def test_wrap_to_code_comment_is_insertion_order_independent() -> None: """The config comment dumps with sorted keys: voluptuous fills schema diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 7b83a1edc7..b78a94a2e7 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -7,8 +7,10 @@ import sys from types import SimpleNamespace from unittest.mock import patch +import platformdirs import pytest +from esphome.components.nrf52 import _resolve_toolchain from esphome.components.nrf52.framework import ( _PLATFORMIO_PENV_REQUIREMENTS, _REQUIREMENTS, @@ -22,8 +24,9 @@ from esphome.components.nrf52.framework import ( get_sdk_nrf_tools_path, setup_platformio_python_env, ) +import esphome.config_validation as cv from esphome.config_validation import Version -from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain from esphome.core import CORE, EsphomeError from esphome.framework_helpers import get_python_env_executable_path @@ -560,7 +563,6 @@ def testget_tools_path_blank_env_falls_back_to_default( Path("") would resolve to the working directory, which clean-all could then delete by accident. """ - import platformdirs monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value) expected = ( @@ -572,7 +574,6 @@ def testget_tools_path_blank_env_falls_back_to_default( def testget_tools_path_default_is_global_cache( monkeypatch: pytest.MonkeyPatch, ) -> None: - import platformdirs monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False) expected = ( @@ -621,3 +622,11 @@ def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> N assert not python.exists() assert _needs_venv_rebuild(python, sentinel, "abc123") + + +def test_resolve_toolchain_rejects_unsupported() -> None: + """A --toolchain nRF52 cannot serve fails instead of degrading silently.""" + + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"): + _resolve_toolchain({})