diff --git a/esphome/__main__.py b/esphome/__main__.py index f104eace8a..42c43c5e11 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -813,7 +813,7 @@ def write_cpp_file() -> int: from esphome.build_gen import espidf espidf.write_project() - elif CORE.using_toolchain_arduino and CORE.is_esp8266: + elif CORE.using_toolchain_arduino: # The ninja project is generated at compile time by # esphome.arduino8266.toolchain (it needs the downloaded framework). pass @@ -968,7 +968,7 @@ def upload_using_esptool( flash_images = [ FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0") ] - elif CORE.using_toolchain_arduino and CORE.is_esp8266: + elif CORE.using_toolchain_arduino: # The native backend writes PlatformIO-compatible output paths, so the # shared property already points at the right file. flash_images = [FlashImage(path=CORE.firmware_bin, offset="0x0")] @@ -1917,14 +1917,27 @@ def command_update_all(args: ArgsProtocol) -> int | None: return run_multiple_configs(files, build_command) +def _native_toolchain_module(): + """The native build backend module for the resolved toolchain, if any. + + Platform toolchain validation rejects values a platform cannot serve, so + using_toolchain_arduino by itself implies the native ESP8266 build. + """ + if CORE.using_toolchain_esp_idf: + from esphome.espidf import toolchain + + return toolchain + if CORE.using_toolchain_arduino: + from esphome.arduino8266 import toolchain + + return toolchain + return None + + def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json - native_toolchain = None - if CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain as native_toolchain - elif CORE.using_toolchain_arduino and CORE.is_esp8266: - from esphome.arduino8266 import toolchain as native_toolchain + native_toolchain = _native_toolchain_module() if native_toolchain is not None: # Native toolchains derive idedata from the build's @@ -1979,11 +1992,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: # Get idedata for analysis idedata = None - native_toolchain = None - if CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain as native_toolchain - elif CORE.using_toolchain_arduino and CORE.is_esp8266: - from esphome.arduino8266 import toolchain as native_toolchain + native_toolchain = _native_toolchain_module() if native_toolchain is not None: objdump_path = str(native_toolchain.get_objdump_path()) diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py index ed3f789326..1cb8d32436 100644 --- a/esphome/arduino8266/toolchain.py +++ b/esphome/arduino8266/toolchain.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import os from pathlib import Path import subprocess @@ -15,7 +14,7 @@ from esphome.const import ( KEY_FRAMEWORK_VERSION, ) from esphome.core import CORE, EsphomeError -from esphome.helpers import write_file_if_changed +from esphome.helpers import IS_WINDOWS, write_file_if_changed from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -58,7 +57,7 @@ def get_elf_path() -> Path: # Windows binutils carry the executable suffix; is_file() checks need it -_EXE_SUFFIX = ".exe" if os.name == "nt" else "" +_EXE_SUFFIX = ".exe" if IS_WINDOWS else "" def _toolchain_tool(name: str) -> Path: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 22d38e36b4..21cec7d4ae 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -118,11 +118,7 @@ def _resolve_toolchain(config: ConfigType) -> ConfigType: # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. if CORE.toolchain is None: CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) - if CORE.toolchain not in (Toolchain.PLATFORMIO, Toolchain.ARDUINO): - raise cv.Invalid( - f"Unsupported toolchain '{CORE.toolchain.value}' for ESP8266. " - "Supported toolchains are 'platformio' and 'arduino'." - ) + cv.check_supported_toolchain("ESP8266", (Toolchain.PLATFORMIO, Toolchain.ARDUINO)) return config @@ -152,6 +148,8 @@ def _validate_native_toolchain(config: ConfigType) -> ConfigType: "'toolchain: arduino' does not support a custom framework source; " "use 'toolchain: platformio'" ) + # BOARDS is a subset of ESP8266_BOARD_BUILD today; the second clause is + # a drift guard for the independently regenerated tables if ( config[CONF_BOARD] not in BOARDS or config[CONF_BOARD] not in ESP8266_BOARD_BUILD @@ -617,10 +615,9 @@ def _decode_pc(config, addr): addr2line = native_toolchain.get_addr2line_path() elf = native_toolchain.get_elf_path() - if not addr2line.is_file() or not elf.is_file(): - _warn_missing_decode_tool( - str(addr2line if not addr2line.is_file() else elf) - ) + missing = addr2line if not addr2line.is_file() else elf + if not missing.is_file(): + _warn_missing_decode_tool(str(missing)) return addr2line, elf = str(addr2line), str(elf) else: diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 36ad09e63a..fc96a67321 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -642,8 +642,8 @@ ESP8266_NATIVE_TEST_COMPONENTS = frozenset( ) # Infrastructure whose changes always trigger the native ESP8266 compile -# test. esphome/espidf/ is included because the backend shares its idedata, -# extra-script, and size-summary helpers. +# test. esphome/build_helpers/ holds the idedata and size-summary helpers +# the backend shares with the native ESP-IDF build. ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = ( "esphome/arduino8266/", "esphome/build_helpers/", @@ -651,6 +651,8 @@ ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = ( ESP8266_NATIVE_TRIGGER_FILES = frozenset( { "esphome/build_gen/arduino8266.py", + "esphome/build_gen/build_tool.py", + "esphome/platformio/extra_script.py", "esphome/components/esp8266/build_surgery.py", "esphome/components/esp8266/boards.py", "esphome/platformio/library.py", diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index f8a02cd410..a5e41c28a0 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -3,11 +3,13 @@ from __future__ import annotations from pathlib import Path +from unittest.mock import patch import pytest from esphome.components.esp8266 import ( ARDUINO_FRAMEWORK_SCHEMA, + _resolve_toolchain, _validate_native_toolchain, ) import esphome.config_validation as cv @@ -16,6 +18,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_PLATFORM_VERSION, CONF_SOURCE, + CONF_TOOLCHAIN, CONF_VERSION, Toolchain, ) @@ -101,9 +104,6 @@ def test_unsupported_board_rejected() -> None: def test_yaml_toolchain_key_resolves() -> None: """The documented `toolchain: arduino` YAML key selects the native path.""" - from esphome.components.esp8266 import _resolve_toolchain - from esphome.const import CONF_TOOLCHAIN - CORE.toolchain = None _resolve_toolchain({CONF_TOOLCHAIN: Toolchain.ARDUINO}) assert CORE.toolchain == Toolchain.ARDUINO @@ -112,8 +112,6 @@ def test_yaml_toolchain_key_resolves() -> None: def test_yaml_toolchain_key_defaults_to_platformio() -> None: CORE.toolchain = None - from esphome.components.esp8266 import _resolve_toolchain - _resolve_toolchain({}) assert CORE.toolchain == Toolchain.PLATFORMIO @@ -122,8 +120,6 @@ def test_decode_pc_native_missing_tools_warns_once( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A stack dump of many addresses produces one missing-tool warning.""" - from unittest.mock import patch - from esphome.components import esp8266 esp8266._warn_missing_decode_tool.cache_clear() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 5418431fae..998e38cb4e 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7150,54 +7150,24 @@ def test_upload_using_esptool_arduino_toolchain( assert cmd_list[firmware_offset_idx + 1] == str(CORE.firmware_bin) -def test_write_cpp_file_arduino_toolchain_writes_no_project(tmp_path: Path) -> None: - """The native ESP8266 Arduino toolchain generates its project at compile - time, so write_cpp_file must not write a platformio.ini.""" - setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") - CORE.toolchain = Toolchain.ARDUINO - - with ( - patch("esphome.writer.write_cpp") as mock_write_cpp, - patch("esphome.build_gen.platformio.write_project") as mock_pio_project, - patch.object( - type(CORE), "cpp_main_section", new_callable=PropertyMock - ) as mock_section, - ): - mock_section.return_value = "" - assert main.write_cpp_file() == 0 - - mock_write_cpp.assert_called_once() - mock_pio_project.assert_not_called() - - -def test_write_cpp_file_platformio_toolchain_writes_project(tmp_path: Path) -> None: - """The default toolchain writes the PlatformIO project files.""" - setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") - - with ( - patch("esphome.writer.write_cpp") as mock_write_cpp, - patch("esphome.build_gen.platformio.write_project") as mock_pio_project, - patch.object( - type(CORE), "cpp_main_section", new_callable=PropertyMock - ) as mock_section, - ): - mock_section.return_value = "" - assert main.write_cpp_file() == 0 - - mock_write_cpp.assert_called_once() - mock_pio_project.assert_called_once() - - -def test_write_cpp_file_arduino_toolchain_other_platform_falls_through( - tmp_path: Path, +@pytest.mark.parametrize( + ("toolchain", "pio_project_written"), + [ + # The native toolchain generates its project at compile time, so + # write_cpp_file must not write a platformio.ini; the default + # toolchain writes the PlatformIO project files. + (Toolchain.ARDUINO, False), + (None, True), + ], +) +def test_write_cpp_file_project_generation_follows_toolchain( + tmp_path: Path, toolchain: Toolchain | None, pio_project_written: bool ) -> None: - """The 'arduino' toolchain is ESP8266-only; other platforms keep the - PlatformIO project generation.""" - setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test") - CORE.toolchain = Toolchain.ARDUINO + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") + CORE.toolchain = toolchain with ( - patch("esphome.writer.write_cpp"), + patch("esphome.writer.write_cpp") as mock_write_cpp, patch("esphome.build_gen.platformio.write_project") as mock_pio_project, patch.object( type(CORE), "cpp_main_section", new_callable=PropertyMock @@ -7206,7 +7176,8 @@ def test_write_cpp_file_arduino_toolchain_other_platform_falls_through( mock_section.return_value = "" assert main.write_cpp_file() == 0 - mock_pio_project.assert_called_once() + mock_write_cpp.assert_called_once() + assert mock_pio_project.called is pio_project_written def test_command_idedata_arduino_prints_json( @@ -7238,33 +7209,34 @@ def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None: assert result == 1 -def test_command_analyze_memory_arduino_toolchain( +@pytest.mark.parametrize( + ("platform", "toolchain", "module"), + [ + (PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), + (PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"), + ], +) +def test_command_analyze_memory_native_toolchains( tmp_path: Path, mock_write_cpp: Mock, mock_compile_program: Mock, mock_get_esphome_components: Mock, mock_memory_analyzer_cli: Mock, mock_ram_strings_analyzer: Mock, + platform: str, + toolchain: Toolchain, + module: str, ) -> None: - """analyze-memory uses the native toolchain's binutils under - 'toolchain: arduino' instead of falling into the PlatformIO branch.""" - setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") - CORE.toolchain = Toolchain.ARDUINO + """analyze-memory uses the native toolchain's binutils instead of + falling into the PlatformIO branch.""" + setup_core(platform=platform, tmp_path=tmp_path, name="test_device") + CORE.toolchain = toolchain config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} with ( - patch( - "esphome.arduino8266.toolchain.get_objdump_path", - return_value=Path("/tc/objdump"), - ), - patch( - "esphome.arduino8266.toolchain.get_readelf_path", - return_value=Path("/tc/readelf"), - ), - patch( - "esphome.arduino8266.toolchain.get_elf_path", - return_value=Path("/build/firmware.elf"), - ), + patch(f"{module}.get_objdump_path", return_value=Path("/tc/objdump")), + patch(f"{module}.get_readelf_path", return_value=Path("/tc/readelf")), + patch(f"{module}.get_elf_path", return_value=Path("/build/firmware.elf")), ): result = command_analyze_memory(MockArgs(), config) @@ -7279,45 +7251,6 @@ def test_command_analyze_memory_arduino_toolchain( ) -def test_command_analyze_memory_esp_idf_toolchain( - tmp_path: Path, - mock_write_cpp: Mock, - mock_compile_program: Mock, - mock_get_esphome_components: Mock, - mock_memory_analyzer_cli: Mock, - mock_ram_strings_analyzer: Mock, -) -> None: - """analyze-memory uses the ESP-IDF toolchain's binutils natively.""" - setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") - CORE.toolchain = Toolchain.ESP_IDF - - config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} - with ( - patch( - "esphome.espidf.toolchain.get_objdump_path", - return_value=Path("/tc/objdump"), - ), - patch( - "esphome.espidf.toolchain.get_readelf_path", - return_value=Path("/tc/readelf"), - ), - patch( - "esphome.espidf.toolchain.get_elf_path", - return_value=Path("/build/firmware.elf"), - ), - ): - result = command_analyze_memory(MockArgs(), config) - - assert result == 0 - mock_memory_analyzer_cli.assert_called_once_with( - str(Path("/build/firmware.elf")), - str(Path("/tc/objdump")), - str(Path("/tc/readelf")), - set(), - idedata=None, - ) - - def test_command_idedata_incompatible_toolchain(tmp_path: Path) -> None: """A non-native, non-platformio toolchain errors out cleanly.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)