diff --git a/esphome/__main__.py b/esphome/__main__.py index 43b205dd53..1875d23576 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1936,24 +1936,27 @@ def command_update_all(args: ArgsProtocol) -> int | None: return run_multiple_configs(files, build_command) +# Native build backend per toolchain; keep in sync with NATIVE_TOOLCHAINS +# in esphome.const. Keyed by toolchain rather than a platform hook so the +# serial upload/logs fast path never imports the platform component package +# (see the esp32 variant comment in upload_using_esptool). +_NATIVE_TOOLCHAIN_MODULES = { + Toolchain.ESP_IDF: "esphome.espidf.toolchain", + Toolchain.ARDUINO: "esphome.arduino8266.toolchain", +} + + def _native_toolchain_module(): - """The native build backend module for the resolved toolchain, via the - target platform's ``native_toolchain_module`` hook.""" + """The native build backend module for the resolved toolchain.""" if not CORE.using_native_toolchain: - # Both hooks return None for PlatformIO anyway; returning early - # keeps the serial upload/logs fast path from importing the - # platform component package (see the esp32 variant comment in - # upload_using_esptool) return None - module = importlib.import_module("esphome.components." + CORE.target_platform) - get_native = getattr(module, "native_toolchain_module", None) - native = get_native() if get_native is not None else None - if native is None and CORE.using_native_toolchain: + if (module_path := _NATIVE_TOOLCHAIN_MODULES.get(CORE.toolchain)) is None: + # A native toolchain missing from the table is a bug; degrading to + # the PlatformIO path would build with the wrong backend raise EsphomeError( - f"Platform {CORE.target_platform} resolved toolchain " - f"'{CORE.toolchain.value}' but provides no native toolchain module" + f"Toolchain '{CORE.toolchain.value}' has no native build backend module" ) - return native + return importlib.import_module(module_path) def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: @@ -2007,7 +2010,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: native_toolchain = _native_toolchain_module() if native_toolchain is None and not CORE.using_toolchain_platformio: _LOGGER.error( - "analyze-memory is not supported with the '%s' toolchain", + "analyze-memory is not supported with the '%s' toolchain; it " + "requires a PlatformIO, ESP-IDF, or native Arduino build", CORE.toolchain.value if CORE.toolchain else "unresolved", ) return 1 diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py index 988210b949..3e6fd2a183 100644 --- a/esphome/arduino8266/toolchain.py +++ b/esphome/arduino8266/toolchain.py @@ -158,7 +158,10 @@ def run_compile(config: ConfigType, verbose: bool) -> int: _LOGGER.error("Build produced no %s", artifact) return 1 - _print_size_summary(build_dir, paths) + if not _print_size_summary(build_dir, paths): + # The cause was already warned; name the consequence so a build + # contributing no RAM/Flash metric is visible to CI harnesses + _LOGGER.warning("Firmware size summary unavailable for this build") from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS try: @@ -243,8 +246,8 @@ def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | N return app_size -def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> None: - """Print the PlatformIO-shaped RAM/Flash lines. +def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool: + """Print the PlatformIO-shaped RAM/Flash lines; False when skipped. The exact shape (including the bar) is parsed by ``script/ci_memory_impact_extract.py``; ``print_size_line`` matches it. @@ -264,10 +267,10 @@ def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> Non # The summary is a bonus artifact like idedata; a truncated # toolchain extraction must not discard an already-linked build _LOGGER.warning("Could not summarize firmware size: %s", err) - return + return False if result.returncode != 0: _LOGGER.warning("Could not summarize firmware size: %s", result.stderr) - return + return False sections: dict[str, int] = {} for line in result.stdout.splitlines(): parts = line.split() @@ -284,17 +287,18 @@ def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> Non "Size output is missing section(s) %s; skipping the size summary", ", ".join(sorted(missing)), ) - return + return False # Resolve the flash budget before printing anything: a RAM line without # its Flash line would let CI's memory-impact extraction sum the two # metrics over different build counts (_parse_app_size already warned). app_size = _parse_app_size(build_dir, paths) if not app_size: - return + return False ram = sum(sections[s] for s in _RAM_SECTIONS) flash = sum(sections[s] for s in _FLASH_SECTIONS) print_size_line("RAM", ram, _MAX_RAM_SIZE) print_size_line("Flash", flash, app_size) + return True # Sentinel: "resolve for me"; None is a real value meaning disabled. diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index bb06d261fe..e3d82faac8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3446,20 +3446,6 @@ def process_stacktrace(config, line, backtrace_state): return backtrace_state -def native_toolchain_module(): - """The native build backend for the resolved toolchain, if any. - - Hook for ``__main__``'s shared dispatch (idedata, analyze_memory); - esp32's crash decode still branches inline in _decode_pc. Same seam - the esp8266 component provides. - """ - if not CORE.using_toolchain_esp_idf: - return None - from esphome.espidf import toolchain - - return toolchain - - # gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which # are instantiated solely by the pin schema codegen (esp32_pin_to_code) FILTER_SOURCE_FILES = filter_source_files_from_defines( diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 81e2865d59..52e48925f2 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -323,7 +323,8 @@ CONFIG_SCHEMA = cv.All( def native_toolchain_module(): """The native build backend for the resolved toolchain, if any. - Hook for ``__main__``'s shared dispatch (idedata, analyze_memory). + ``__main__`` dispatches from its own toolchain-keyed table; this helper + serves the component's internal callers. """ if not CORE.using_toolchain_arduino: return None @@ -476,10 +477,9 @@ async def to_code(config: ConfigType) -> None: # implementation in the Arduino ESP8266 core. cg.add_build_flag("-Wl,--wrap=millis") - if use_platformio: - cg.add_platformio_option( - "board_build.flash_mode", config[CONF_BOARD_FLASH_MODE] - ) + # Unconditional: the native build generator reads the same option, + # keeping one source of truth + cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] cg.add_define( diff --git a/esphome/core/config.py b/esphome/core/config.py index a0c5022379..03a7f28596 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -564,11 +564,13 @@ NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscri # 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. -# build_src_flags: set unconditionally by esp8266/__init__ (throw_stubs) and -# read by the native generator; not user-routable, so not in the set above +# build_src_flags and board_build.flash_mode: set unconditionally by +# esp8266/__init__ and read by the native generator; not user-routable, so +# not in the set above NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | { "lib_ignore", "build_src_flags", + "board_build.flash_mode", } diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py index 44916453c9..bba31719f4 100644 --- a/tests/unit_tests/test_arduino8266_toolchain.py +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -500,12 +500,13 @@ def test_print_size_summary_missing_section_skips_summary( def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None: """Component-added options the native build drops are warned by name; - the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags) stay - quiet.""" + the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags, + flash_mode) stay quiet.""" CORE.platformio_options = { "board_build.ldscript": "eagle.flash.4m2m.ld", "board_build.f_cpu": "160000000L", "board_build.filesystem": "littlefs", + "board_build.flash_mode": "dio", "build_src_flags": "-include throw_stubs.h", "lib_ignore": ["Updater"], "upload_speed": "460800", @@ -517,6 +518,7 @@ def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> No assert "board_build.f_cpu is ignored" not in caplog.text assert "lib_ignore" not in caplog.text assert "build_src_flags" not in caplog.text + assert "flash_mode" not in caplog.text # Component-added upload_speed never gets read under the native # toolchain, so it must warn assert "upload_speed" in caplog.text diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bfad6708a0..07698d68cf 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7390,12 +7390,15 @@ def test_compile_program_espidf_idedata_none_warns( assert "No idedata was generated" in caplog.text -def test_native_toolchain_module_missing_hook_raises(tmp_path: Path) -> None: - """A resolved native toolchain whose platform lacks the hook is a bug - and must fail, not silently degrade to the PlatformIO path.""" - setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") - CORE.toolchain = Toolchain.ARDUINO # esp32 provides no hook - with pytest.raises(EsphomeError, match="no native toolchain module"): +def test_native_toolchain_module_missing_backend_raises(tmp_path: Path) -> None: + """A native toolchain missing from the backend table is a bug and must + fail, not silently degrade to the PlatformIO path.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO + with ( + patch.dict(main._NATIVE_TOOLCHAIN_MODULES, clear=True), + pytest.raises(EsphomeError, match="no native build backend"), + ): _native_toolchain_module()