From 04ad9524f52644cc4ad95cc0c8d969f136915409 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 12:56:34 -0500 Subject: [PATCH] Add the arduino toolchain enum and validate --toolchain on every platform --- esphome/components/esp32/__init__.py | 5 ++++ esphome/components/esp8266/__init__.py | 3 ++ esphome/components/host/__init__.py | 1 + esphome/components/libretiny/__init__.py | 1 + esphome/components/nrf52/__init__.py | 5 ++++ esphome/components/rp2/__init__.py | 1 + esphome/config_validation.py | 22 +++++++++++++++ esphome/const.py | 2 ++ esphome/core/__init__.py | 4 +++ esphome/core/config.py | 13 +++++---- tests/component_tests/esp32/test_esp32.py | 14 +++++++++ tests/unit_tests/core/test_config.py | 33 ++++++++++++++++++++++ tests/unit_tests/test_config_validation.py | 16 +++++++++++ tests/unit_tests/test_nrf52_framework.py | 11 ++++++++ 14 files changed, 126 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d6ed6d9399..a8a5abab25 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1083,6 +1083,11 @@ def _resolve_toolchain(value: ConfigType) -> ConfigType: # 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) + if CORE.toolchain not in (Toolchain.PLATFORMIO, Toolchain.ESP_IDF): + raise cv.Invalid( + f"Unsupported toolchain '{CORE.toolchain.value}' for ESP32. " + "Supported toolchains are 'platformio' and 'esp-idf'." + ) return value diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 2161a902cb..abc2f5dc91 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -244,6 +244,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 b6a3b8b615..6f0fcb9d52 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -37,6 +37,7 @@ CONFIG_SCHEMA = cv.All( } ), set_core_data, + cv.require_platformio_toolchain("host"), ) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index c56cc48055..f83593269e 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -315,6 +315,7 @@ BASE_SCHEMA = cv.Schema( BASE_SCHEMA.add_extra(_detect_variant) BASE_SCHEMA.add_extra(_update_core_data) +BASE_SCHEMA.add_extra(cv.require_platformio_toolchain("LibreTiny")) def _configure_lwip(config: dict) -> None: diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 2d25558254..2328d444f7 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -128,6 +128,11 @@ def set_core_data(config: ConfigType) -> ConfigType: def _resolve_toolchain(config: ConfigType) -> ConfigType: if CORE.toolchain is None: CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF) + if CORE.toolchain not in (Toolchain.PLATFORMIO, Toolchain.SDK_NRF): + raise cv.Invalid( + f"Unsupported toolchain '{CORE.toolchain.value}' for nRF52. " + "Supported toolchains are 'platformio' and 'sdk-nrf'." + ) return config diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index ed975ec01a..14806b26ce 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -313,6 +313,7 @@ CONFIG_SCHEMA = cv.All( cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT), _detect_variant, set_core_data, + cv.require_platformio_toolchain("RP2"), ) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index f455c7b8bf..c3ee760761 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2532,6 +2532,28 @@ def platformio_version_constraint(value): return constraints +def require_platformio_toolchain(platform_name: str): + """Reject a CLI-selected toolchain other than PlatformIO. + + For platforms with only the PlatformIO backend; without this a + ``--toolchain`` they cannot serve would silently build with PlatformIO. + """ + + def validator(config): + from esphome.const import Toolchain + + if CORE.toolchain is None: + CORE.toolchain = Toolchain.PLATFORMIO + if CORE.toolchain != Toolchain.PLATFORMIO: + raise Invalid( + f"Unsupported toolchain '{CORE.toolchain.value}' for " + f"{platform_name}. The only supported toolchain is 'platformio'." + ) + return config + + return validator + + def require_framework_version( *, max_version=False, diff --git a/esphome/const.py b/esphome/const.py index 0dd948544f..6e9378ec9a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -21,6 +21,8 @@ class Toolchain(StrEnum): PLATFORMIO = "platformio" ESP_IDF = "esp-idf" SDK_NRF = "sdk-nrf" + # ESP8266: the Arduino core built directly (no PlatformIO) + ARDUINO = "arduino" class Platform(StrEnum): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 0f1ac9213e..8e9f8e9751 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -982,6 +982,10 @@ class EsphomeCore: def using_toolchain_sdk_nrf(self): return self.toolchain == Toolchain.SDK_NRF + @property + def using_toolchain_arduino(self): + return self.toolchain == Toolchain.ARDUINO + @property def using_zephyr(self): return self.target_framework == "zephyr" diff --git a/esphome/core/config.py b/esphome/core/config.py index 1095a4886e..be577cccbb 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -557,10 +557,12 @@ def _add_library_str(lib: str) -> None: @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_toolchain_esp_idf or ( + CORE.using_toolchain_arduino and CORE.is_esp8266 + ): + # 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: @@ -588,8 +590,9 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No # 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 7208318d3a..370fd2a2dc 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -131,6 +131,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..80e7f3b5c2 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1389,3 +1389,36 @@ 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 warns about ignored options the + same way the native IDF toolchain does.""" + 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", + "upload_speed": "115200", + } + ) + + assert "esphome->platformio_options->board_build.f_cpu 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_config_validation.py b/tests/unit_tests/test_config_validation.py index 971c4e462d..a7ddb931b3 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -3165,3 +3165,19 @@ 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.""" + from esphome.const import Toolchain + from esphome.core import CORE + + 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) diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index c2ee0c2a75..b5f6b5794f 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -619,3 +619,14 @@ 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.""" + from esphome.components.nrf52 import _resolve_toolchain + import esphome.config_validation as cv + from esphome.const import Toolchain + + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"): + _resolve_toolchain({})