From f4300d9ceebdb012141fd4fcb612c462311591af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:45:54 +0100 Subject: [PATCH] [esp8266] Add the native ninja emission (#18560) --- esphome/build_gen/arduino8266.py | 502 +++++++++++++++- esphome/components/esp8266/__init__.py | 6 +- esphome/components/esp8266/const.py | 4 +- .../unit_tests/build_gen/test_arduino8266.py | 553 +++++++++++++++++- 4 files changed, 1032 insertions(+), 33 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 7d842308f6..b6ac88f123 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1,11 +1,11 @@ -"""Build specification for the native ESP8266 Arduino toolchain. +"""Native ninja build generator for the ESP8266 Arduino core. Transliterates the PlatformIO build spec for the Arduino ESP8266 framework (``framework-arduinoespressif8266/tools/platformio-build.py`` plus -``platform-espressif8266/builder/main.py``): the flag sets, defines, and -linker-script generation deliberately match what PlatformIO produces so the -binaries stay near-identical between the two toolchains. The ninja emission -(``write_project``) builds on these pieces. +``platform-espressif8266/builder/main.py``) into a ``build.ninja`` under +``.pioenvs//``. The flag sets, defines, link line, linker-script +generation, and ``elf2bin`` invocation deliberately match what PlatformIO +produces so the binaries stay near-identical between the two toolchains. The ``PIO_FRAMEWORK_ARDUINO_*`` knob defines (lwIP variant, NONOS SDK version, MMU layout, exceptions, waveform phase) keep working: they are read @@ -14,6 +14,7 @@ from the build flags with the same precedence as the PlatformIO builder. from __future__ import annotations +from collections.abc import Collection from dataclasses import dataclass import hashlib import logging @@ -22,20 +23,51 @@ from pathlib import Path import re import shlex import subprocess +import sys from typing import TYPE_CHECKING, NamedTuple from esphome.arduino8266.framework import toolchain_tool -from esphome.build_helpers.ninja import shell_token as _shell_token +from esphome.build_helpers.ninja import ( + escape as _e, + quote_path as _q, + shell_token as _shell_token, +) from esphome.components.esp8266 import build_surgery +from esphome.components.esp8266.boards import ( + BOARDS, + ESP8266_BOARD_BUILD, + board_ld_script, +) +from esphome.components.esp8266.const import ( + BUILD_FLASH_MODES, + KEY_BOARD, + KEY_ESP8266, + KEY_FLASH_SIZE, + KEY_SCANF_FLOAT, +) from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import ( + get_project_cxx_compile_flags, + strip_win_long_path_prefix, +) from esphome.helpers import mkdir_p, write_file_if_changed -from esphome.platformio.library import lex_build_flags +from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX, lex_build_flags if TYPE_CHECKING: from esphome.arduino8266.framework import InstalledPaths _LOGGER = logging.getLogger(__name__) +# Always excluded from the core build: ESPHome uses its own native OTA +# backend, so the Arduino Updater (and its 228-byte global) never links. +_CORE_EXCLUDE_ALWAYS = {"Updater.cpp"} +# Excluded when no component called require_waveform(); waveform_stubs.cpp +# supplies the stopWaveform()/_stopPWM() stubs digitalWrite needs. +_CORE_EXCLUDE_WAVEFORM = { + "core_esp8266_waveform_pwm.cpp", + "core_esp8266_waveform_phase.cpp", +} + # Values that land unquoted on generated command lines are shape-checked # against these before use. re.ASCII: a Unicode digit or word character # (Arabic-Indic numerals) would pass \d/\w and defeat the named error @@ -77,6 +109,11 @@ def _apply_surgery(fn, *args: object) -> str: raise EsphomeError(str(err)) from err +# Every supported board's f_flash is 40 MHz; re-check on a platform bump +# board_flash_mode's closed set, shared with cv.one_of's validation +_FLASH_MODES = frozenset(BUILD_FLASH_MODES) +_FLASH_FREQ_MHZ = 40 + # From platformio-build.py. Knob suffix -> SDK define; the first entry is # the default (dicts preserve insertion order). With multiple SDK knobs set # (a pathological config) ties break by table order, since upstream's @@ -408,6 +445,52 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: ) +_INCOMPLETE_INSTALL = "Arduino toolchain install is incomplete" + + +def _filter_link_flags(unflags: set[str]) -> list[str]: + """_LINKFLAGS minus ``unflags``, pair-aware: unflagging a symbol also + drops the ``-u`` that carried it, so no dangling operand-less flag + reaches ld as the next token's consumer.""" + out: list[str] = [] + it = iter(_LINKFLAGS) + for tok in it: + if tok == "-u": + symbol = next(it) + if symbol not in unflags: + out += [tok, symbol] + elif tok not in unflags: + out.append(tok) + return out + + +def _active_flash_ld_name(flash_ld_name: str) -> str: + """The flash linker-script filename the link uses (testing mode renames + the surgically patched copy).""" + return ( + f"{_TESTING_LD_PREFIX}{flash_ld_name}" if CORE.testing_mode else flash_ld_name + ) + + +def _flash_ld_name(board: str) -> str: + """The flash linker script: the board's, or a routed user override. + + Published configs override board_build.ldscript to reserve a + filesystem region or correct a board's assumed flash size; a bare + name is required because the script resolves via the -L search path. + """ + override = _pio_option("board_build.ldscript", "") + if not override: + # The same shared rule the PlatformIO path pins (layout + # preservation, see boards.board_ld_script) + return board_ld_script(BOARDS[board]) + if Path(override).name != override: + raise EsphomeError( + f"board_build.ldscript must be a bare script name, got {override!r}" + ) + return override + + def _pio_option(key: str, default: str) -> str: """A platformio_options value the native build honors (str-normalized). @@ -551,11 +634,23 @@ _PLAIN_LINKER_FLAGS = ( "-nostdlib", "-rdynamic", ) +# The subset whose next token is an operand; unflagging the bare flag +# would strand the operand. Operand-less members of the list above filter +# whole-token from both the compile and link lines, as PlatformIO allows. +_PLAIN_LINKER_OPERAND_FLAGS = ("-u", "-e") _PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker") # Driver options, not ld options: -Wl, has no equivalent for these _PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=") +def _collect_sources(root: Path, exclude: Collection[str] = frozenset()) -> list[Path]: + return sorted( + p + for p in root.rglob("*") + if p.suffix in SOURCE_KIND_FOR_SUFFIX and p.name not in exclude + ) + + def _stat_sig(path: Path) -> str: """Size and mtime cache-stamp signature for one input file. @@ -771,3 +866,396 @@ def _generate_testing_flash_ld( write_file_if_changed( ld_dir / f"{_TESTING_LD_PREFIX}{flash_ld_name}", patched_flash_ld ) + + +def _ninja_compile_edges( + lines: list[str], + sources: list[Path], + root: Path, + group: str, + flags: str = "", +) -> list[str]: + """Emit compile edges for ``sources``; return the object paths.""" + objects = [] + for src in sources: + rel = src.relative_to(root).as_posix() + obj = f"obj/{group}/{rel}.o" + escaped_obj = _e(obj) + lines.append( + f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}" + ) + if flags: + lines.append(f" flags = {flags}") + # Escaped once here: the returned paths only ever appear in build + # statements (archive/link inputs), which use ninja escaping. + objects.append(escaped_obj) + return objects + + +def _common_parent(paths: list[Path]) -> Path: + return Path(os.path.commonpath([str(p.parent) for p in paths])) + + +class _FlagSets(NamedTuple): + cflags: list[str] + cxxflags: list[str] + asflags: list[str] + + +def _check_install(framework: Path, src_dir: Path, include_dirs: list[Path]) -> None: + """Fail by naming the missing path before any tool runs.""" + if not src_dir.is_dir(): + # Generated project state, not install state: clean-all would not help + raise EsphomeError(f"Generated source directory {src_dir} is missing") + # A missing install directory would otherwise surface as a wall of + # include errors; failing here names the path instead + for required in include_dirs[1:]: + if not required.is_dir(): + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: missing {required}; {_CLEAN_HINT}" + ) + # The elf2bin edge runs after the full compile and link; a + # half-extracted package must fail here, not an hour of wall-clock later + for required_file in ( + framework / "tools" / "elf2bin.py", + framework / "bootloaders" / "eboot" / "eboot.elf", + ): + if not required_file.is_file(): + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: missing {required_file}; {_CLEAN_HINT}" + ) + + +def _resolve_flag_sets( + config: _BuildConfig, + defines: list[str], + includes: list[str], + project_compile_flags: list[str], + build_tokens: list[str], + unflags: set[str], +) -> _FlagSets: + """The c/cxx/as flag sets with ``build_unflags`` applied.""" + common = _CCFLAGS + defines + includes + project_compile_flags + cflags = _CFLAGS + common + cpp_standard = CORE.cpp_standard or "gnu++17" + cxxflags = ( + ["-fno-rtti", f"-std={cpp_standard}"] + + ["-fexceptions" if config.exceptions else "-fno-exceptions"] + + common + + [_shell_token(f) for f in get_project_cxx_compile_flags()] + ) + # PlatformIO's ASPPCOM passes only -D/-I user flags to assembly; match + # it (tokens arrive shell-quoted, hence the lstrip) + asflags = ( + _ASFLAGS + + defines + + includes + + [f for f in project_compile_flags if f.lstrip("\"'").startswith(("-D", "-I"))] + ) + # build_unflags applies to the framework flag sets too, as under + # PlatformIO; matching is whole-token, so an unflag that hits nothing + # anywhere must be visible + flag_universe = set(build_tokens) + for flags in (cflags, cxxflags, asflags, _LINKFLAGS): + flag_universe.update(flags) + if unmatched := sorted(unflags - flag_universe): + _LOGGER.warning( + "build_unflags entries matched no build flag: %s", ", ".join(unmatched) + ) + # _LINKFLAGS stores -u and its operand as two tokens; unflagging the + # bare -u would strip all seven and leave the operands as ld "input + # files" with an error pointing nowhere near build_unflags + if plain := sorted( + u + for u in unflags + if u in _PLAIN_LINKER_OPERAND_FLAGS or u.startswith(_PLAIN_LINKER_PREFIXES) + ): + raise EsphomeError( + f"build_unflags cannot remove plain linker flag(s) " + f"{', '.join(plain)}; unflag the full -Wl, form or the symbol" + ) + return _FlagSets( + *( + [f for f in flags if f not in unflags] + for flags in (cflags, cxxflags, asflags) + ) + ) + + +def write_project(paths: InstalledPaths, ccache: str | None) -> bool: + """Write the ninja build for the current configuration. + + ``ccache`` is the caller's already-resolved binary (None when disabled) + so one build never pays the runnability probe per consumer. Returns + True when ``build.ninja`` changed, so the caller can skip work derived + purely from it (the compile database) on unchanged builds. + """ + from esphome.arduino.library import resolve_libraries + + framework = paths.framework + toolchain_bin = paths.toolchain / "bin" + build_dir = CORE.relative_pioenvs_path(CORE.name) + mkdir_p(build_dir) + + unflags = _unflag_tokens() + # Lexed once so a malformed entry warns once, not per consumer + build_tokens = _lexed_build_flags() + flag_defines = _flag_defines(unflags, build_tokens) + config = _resolve_build_config(flag_defines) + esp8266_data = CORE.data[KEY_ESP8266] + board = esp8266_data[KEY_BOARD] + # Config validation already gates boards; + # kept as defense-in-depth for direct calls, since CONF_BOARD itself is + # a free-form string + if board not in ESP8266_BOARD_BUILD: + raise EsphomeError(f"Board '{board}' is not supported by the native toolchain") + board_build = ESP8266_BOARD_BUILD[board] + # From the same producer the PlatformIO path reads (one source) + flash_mode = _pio_option("board_build.flash_mode", "dout") + if flash_mode not in _FLASH_MODES: + # Lands unquoted in the elf2bin command and a -D body; validation + # (cv.one_of on board_flash_mode) already gates it, defense-in-depth + raise EsphomeError(f"Invalid flash mode {flash_mode!r}") + flash_ld_name = _flash_ld_name(board) + + sdk = framework / "tools" / "sdk" + core_dir = framework / "cores" / "esp8266" + variant_dir = framework / "variants" / board_build["variant"] + src_dir = CORE.relative_src_path() + + include_dirs = [ + src_dir, + sdk / "include", + core_dir, + paths.toolchain / "include", + sdk / "lwip2" / "include", + variant_dir, + ] + # Completeness checks run before generate_ld_scripts spawns gcc so a + # half-extracted install names the missing path, not a gcc error + _check_install(framework, src_dir, include_dirs) + + generate_ld_scripts(paths, config, flash_ld_name) + + libraries = resolve_libraries( + framework, + pio_platform="espressif8266", + board_mcu="esp8266", + cache_key="arduino8266", + ) + for lib in libraries: + include_dirs += lib.include_dirs + + ( + project_compile_flags, + project_link_flags, + project_lib_dirs, + project_libs, + ) = _project_flags(unflags, build_tokens) + flag_sets = _resolve_flag_sets( + config, + _defines_flags(config, flash_mode, board, board_build["defines"]), + [f"-I{_q(d)}" for d in include_dirs], + project_compile_flags, + build_tokens, + unflags, + ) + link_flags = _filter_link_flags(unflags) + if esp8266_data[KEY_SCANF_FLOAT]: + link_flags += ["-u", "_scanf_float"] + link_flags += project_link_flags + link_flags += [_shell_token(flag) for lib in libraries for flag in lib.link_flags] + flash_ld = _active_flash_ld_name(flash_ld_name) + # A user-overridden script name re-quotes like every other user token + link_flags += ["-T", _shell_token(flash_ld)] + + lib_dirs = [Path("ld"), sdk / "lib", sdk / "ld", sdk / "lib" / config.nonosdk] + lib_dirs += project_lib_dirs + for lib in libraries: + lib_dirs += lib.link_dirs + system_libs = ( + _SYSTEM_LIBS_PRE_LWIP + + [config.lwip_lib] + + _SYSTEM_LIBS_POST_LWIP + + project_libs + + [lib_name for lib in libraries for lib_name in lib.link_libs] + + ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"] + ) + + build_tool = Path(__file__).parent / "build_tool.py" + + # $in/$out stay unquoted: ninja escapes its built-in path variables + # itself; only literal paths need _q(). + lines = [ + "# Auto-generated by ESPHome", + "ninja_required_version = 1.5", + f"cc = {_q(toolchain_tool(paths.toolchain, 'gcc'))}", + f"cxx = {_q(toolchain_tool(paths.toolchain, 'g++'))}", + # The NSIS launcher starts Python with a \\?\ extended-length path + # that cmd.exe cannot spawn; same strip every other emitted binary + # path gets + f"python = {_q(strip_win_long_path_prefix(sys.executable))}", + f"buildtool = {_q(build_tool)}", + f"ccache = {_q(ccache) if ccache else ''}", + "", + # Rule names match SOURCE_KIND_FOR_SUFFIX values (c, cxx, asm, aspp) + "rule c", + " command = $ccache $cc -MMD -MF $out.d $cflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CC $out", + "rule cxx", + " command = $ccache $cxx -MMD -MF $out.d $cxxflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CXX $out", + "rule aspp", + " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $asflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = AS $out", + # Plain assembler, as SCons's ASCOM: no preprocessor, so no + # depfile and no $flags (defines/includes) either + "rule asm", + " command = $ccache $cc -x assembler $asflags -c $in -o $out", + " description = AS $out", + "rule ar", + f" command = $python $buildtool ar {_q(toolchain_tool(paths.toolchain, 'ar'))} $out $out.rsp", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = AR $out", + "rule link", + " command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = LINK $out", + "rule elf2bin", + # --flash_size deliberately stays board-derived, as under + # PlatformIO (which reads upload.maximum_size, not the ldscript). + f" command = $python {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out", + " description = BIN $out", + "rule copy", + " command = $python $buildtool copy $in $out", + " description = COPY $out", + "", + f"cflags = {' '.join(flag_sets.cflags)}", + f"cxxflags = {' '.join(flag_sets.cxxflags)}", + f"asflags = {' '.join(flag_sets.asflags)}", + f"linkflags = {' '.join(link_flags)}", + f"libdirflags = {' '.join(f'-L{_q(d)}' for d in lib_dirs)}", + f"libflags = {' '.join(_shell_token(f'-l{lib}') for lib in system_libs)}", + "", + ] + + core_exclude = set(_CORE_EXCLUDE_ALWAYS) + if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines: + core_exclude |= _CORE_EXCLUDE_WAVEFORM + + archives = [] + direct_objs: list[str] = [] + # variant_dir existence was already enforced with the include dirs + variant_sources = _collect_sources(variant_dir) + if variant_sources: + objs = _ninja_compile_edges(lines, variant_sources, variant_dir, "variant") + lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") + archives.append("libFrameworkArduinoVariant.a") + + core_objs = _ninja_compile_edges( + lines, _collect_sources(core_dir, core_exclude), core_dir, "core" + ) + if not core_objs: + # An empty archive would link into a wall of undefined references + # (app_entry, the exception vectors) far from the cause + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" + ) + lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") + archives.append("libFrameworkArduino.a") + + for lib in libraries: + if not lib.sources: + # Header-only libraries are legitimate; the log makes an empty + # srcFilter or broken tree traceable before link errors do. + _LOGGER.debug( + "Library %s has no source files; contributing includes only", + lib.name, + ) + continue + lib_root = _common_parent(lib.sources) + objs = _ninja_compile_edges( + lines, + lib.sources, + lib_root, + f"lib/{lib.name}", + flags=" ".join(_shell_token(f) for f in lib.flags), + ) + if not lib.lib_archive: + # libArchive: false / dot_a_linkage=false: hand the objects to + # the linker directly so unreferenced-but-required symbols + # (exception handlers, weak overrides) survive + direct_objs.extend(objs) + continue + archive = f"lib{lib.name}.a" + lines.append(f"build {_e(archive)}: ar {' '.join(objs)}") + archives.append(archive) + + # One source of truth with the PlatformIO path: esp8266/__init__ pins + # build_src_flags (the throw_stubs force-include); -include paths + # resolve against the source root + src_parts: list[str] = [] + src_it = iter( + lex_build_flags(_pio_option("build_src_flags", ""), "build_src_flags") + ) + for tok in src_it: + if tok == "-include": + header = next(src_it, "") + if not header: + raise EsphomeError( + "build_src_flags has a trailing '-include' with no header" + ) + src_parts.append(f"-include {_q(src_dir / header)}") + else: + src_parts.append(_shell_token(tok)) + src_extra = " ".join(src_parts) + # One shared variable instead of repeating the flags line on every src + # edge (hundreds of edges in a real project) + lines.append(f"srcflags = {src_extra}") + src_objs = _ninja_compile_edges( + lines, _collect_sources(src_dir), src_dir, "src", flags="$srcflags" + ) + + ld_deps = [f"ld/{_COMMON_LD_NAME}"] + if CORE.testing_mode: + ld_deps.append(f"ld/{flash_ld}") + lines.append( + f"build firmware.elf: link {' '.join(src_objs + direct_objs)} | " + f"{' '.join(_e(a) for a in archives)} {' '.join(_e(d) for d in ld_deps)}" + ) + lines.append(f" archives = {' '.join(_shell_token(a) for a in archives)}") + lines.append("build firmware.bin: elf2bin firmware.elf") + lines.append("build firmware.factory.bin: copy firmware.bin") + lines.append("build firmware.ota.bin: copy firmware.bin") + lines.append("default firmware.factory.bin firmware.ota.bin") + lines.append("") + + return write_file_if_changed(build_dir / "build.ninja", "\n".join(lines)) + + +def get_flash_ld_path(build_dir: Path, paths: InstalledPaths) -> Path: + """The flash linker script the link actually uses (for size reporting). + + Reads the same install the ninja file linked against instead of + re-resolving the framework version. A user-shipped override living in a + custom -L dir resolves to a nonexistent path here; the size consumer + warns and skips the Flash summary then. + """ + name = _active_flash_ld_name(_flash_ld_name(CORE.data[KEY_ESP8266][KEY_BOARD])) + if CORE.testing_mode: + return build_dir / "ld" / name + return paths.framework / "tools" / "sdk" / "ld" / name + + +def _flash_size_str(flash_size: int) -> str: + """Flash size argument for elf2bin (e.g. ``4M``, ``512K``).""" + mb = 1024 * 1024 + return f"{flash_size // mb}M" if flash_size >= mb else f"{flash_size // 1024}K" diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index cef0e6ea11..56509b9fab 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -37,6 +37,7 @@ from esphome.types import ConfigType from .boards import BOARDS, board_ld_script from .const import ( + BUILD_FLASH_MODES, CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, CONF_ENABLE_SERIAL1, @@ -44,6 +45,7 @@ from .const import ( KEY_BOARD, KEY_ESP8266, KEY_PIN_INITIAL_STATES, + KEY_SCANF_FLOAT, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, @@ -233,7 +235,6 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( ) -BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -315,6 +316,9 @@ async def to_code(config: ConfigType) -> None: "enabling scanf float support (~8KB flash)" ) + # The native generator reads the same decision (KEY_SCANF_FLOAT) + CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float) + extra_scripts = [ "pre:ccache.py", "pre:testing_mode.py", diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 50f103ed2d..ac2fe0aa3b 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -16,7 +16,6 @@ KEY_WAVEFORM_REQUIRED = "waveform_required" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator -KEY_FLASH_MODE = "flash_mode" KEY_SCANF_FLOAT = "scanf_float" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" @@ -73,3 +72,6 @@ def enable_serial1() -> None: enable_serial1() """ CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True + + +BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout") diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 21a81804ab..4ad88347e2 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -1,38 +1,34 @@ -"""Drift tests for the native ESP8266 Arduino build spec. +"""Drift tests for the native ESP8266 Arduino build generator. -These pin the ESPHome side of the transliteration (the knob-define -precedence, the define/flag sets, and the linker-script generation) against -literals audited from the PlatformIO builder -(framework-arduinoespressif8266/tools/platformio-build.py and -platform-espressif8266/builder/main.py). They catch an accidental edit on -this side; an upstream change in a new framework release is caught by the -A/B byte-identical build check on a version bump, not by these tests. +Pin the transliterated flag/define/link sets against literals audited from +the PlatformIO builder. Upstream drift is caught by the A/B build check on +version bumps, not here. """ from __future__ import annotations from collections.abc import Generator +import logging import os from pathlib import Path +import shutil from unittest.mock import MagicMock, patch import pytest +from esphome.arduino.library import ArduinoLibrary from esphome.arduino8266.framework import InstalledPaths, toolchain_tool from esphome.build_gen import arduino8266 from esphome.build_gen.arduino8266 import ( _defines_flags, _flag_defines, + _flash_size_str, _resolve_build_config, + get_flash_ld_path, ) -from esphome.components.esp8266.boards import ESP8266_BOARD_BUILD +from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD from esphome.components.esp8266.build_surgery import RATETABLE_RULE -from esphome.components.esp8266.const import ( - KEY_BOARD, - KEY_ESP8266, - KEY_FLASH_MODE, - KEY_SCANF_FLOAT, -) +from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -47,9 +43,13 @@ def _setup_core(tmp_path: Path) -> Generator[None]: CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)} CORE.data[KEY_ESP8266] = { KEY_BOARD: "nodemcuv2", - KEY_FLASH_MODE: "dout", KEY_SCANF_FLOAT: False, } + # The producers esp8266/__init__ pins unconditionally + CORE.platformio_options = { + "board_build.flash_mode": "dout", + "build_src_flags": "-include esphome/components/esp8266/throw_stubs.h", + } yield # CORE.reset() (the suite-wide autouse fixture) does not clear this flag CORE.testing_mode = False @@ -59,6 +59,11 @@ def _set_flags(*flags: str) -> None: CORE.build_flags = set(flags) +def _shq(tok: str) -> str: + """The platform's shell_token quote wrapper (argv rule on Windows).""" + return f'"{tok}"' if os.name == "nt" else f"'{tok}'" + + def _resolve(*flags: str) -> arduino8266._BuildConfig: """Set the build flags and resolve the knob config in one step.""" _set_flags(*flags) @@ -206,12 +211,173 @@ def _make_framework(tmp_path: Path) -> InstalledPaths: for sub in ("include", "ld", "lwip2/include", "lib"): (framework / "tools" / "sdk" / sub).mkdir(parents=True) (framework / "libraries").mkdir() + (framework / "tools" / "elf2bin.py").write_text("") + eboot = framework / "bootloaders" / "eboot" + eboot.mkdir(parents=True) + (eboot / "eboot.elf").write_text("") toolchain = tmp_path / "toolchain" (toolchain / "bin").mkdir(parents=True) (toolchain / "include").mkdir() return InstalledPaths(framework=framework, toolchain=toolchain, ninja=Path("ninja")) +def _write_ninja( + paths: InstalledPaths, + libraries: list | None = None, + ccache: str | None = None, +) -> str: + src = CORE.relative_src_path() + (src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + (src / "esphome" / "vendor.c").write_text("") + + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch( + "esphome.arduino.library.resolve_libraries", + return_value=libraries or [], + ), + ): + arduino8266.write_project(paths, ccache) + return (CORE.relative_pioenvs_path(CORE.name) / "build.ninja").read_text() + + +def test_write_project_rejects_bad_flash_mode(tmp_path: Path) -> None: + """A flash mode outside the closed set fails by name before landing + unquoted in the elf2bin command.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["board_build.flash_mode"] = "dout; rm -rf /" + with pytest.raises(EsphomeError, match="Invalid flash mode"): + _write_ninja(paths) + + +def test_write_project_flash_mode_reaches_define_and_elf2bin( + tmp_path: Path, +) -> None: + """A non-default board_flash_mode lands in both the compile define and + the elf2bin image header, never silently falling back to dout.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["board_build.flash_mode"] = "dio" + content = _write_ninja(paths) + assert "-DFLASHMODE_DIO" in content + assert "--flash_mode dio" in content + assert "FLASHMODE_DOUT" not in content + + +def test_write_project_trailing_include_raises(tmp_path: Path) -> None: + """A dangling -include must fail by name, not become -include .""" + paths = _make_framework(tmp_path) + CORE.platformio_options["build_src_flags"] = "-include" + with pytest.raises(EsphomeError, match="trailing '-include'"): + _write_ninja(paths) + + +def test_write_project_passes_other_src_flags_through(tmp_path: Path) -> None: + """Non-include build_src_flags tokens are requoted onto the src edges.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["build_src_flags"] = ( + "-include esphome/components/esp8266/throw_stubs.h -DSRC_ONLY=1" + ) + content = _write_ninja(paths) + assert "throw_stubs.h" in content + assert "-DSRC_ONLY=1" in content + + +def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + "-DUSE_ESP8266_WAVEFORM_STUBS", + "-Wl,--wrap=millis", + "-Wl,--wrap=printf", + "-Wno-nonnull-compare", + "-L/opt/blobs", + "-luser_blob", + "-L /spc/blobs -l spaced_blob", + ) + content = _write_ninja(paths) + + # Base link flags from the PlatformIO builder + for flag in ( + "-Wl,--no-check-sections", + "-Wl,-static", + "-Wl,--gc-sections", + "-Wl,-wrap,system_restart_local", + "-Wl,-wrap,spi_flash_read", + "-u app_entry", + "-u _printf_float", + "-u _DebugExceptionVector", + "-u _DoubleExceptionVector", + "-u _KernelExceptionVector", + "-u _NMIExceptionVector", + "-u _UserExceptionVector", + ): + assert flag in content + # ESPHome's link flags and the board linker script + assert "-Wl,--wrap=millis" in content + assert "-Wl,--wrap=printf" in content + assert "-T eagle.flash.4m.ld" in content + # scanf float disabled: the forced-link flag must not appear + assert "_scanf_float" not in content + # $in/$out must stay unquoted; ninja escapes its own path variables + assert "-c $in -o $out" in content + assert "--app $in --flash_mode" in content + assert '"$in"' not in content + assert '"$out"' not in content + # -L/-l from esphome build_flags reach the link line, not the compiles; + # spaced forms ("-L /path") are shell-lexed the way PlatformIO does. + # str(Path(...)) so the separator matches the host platform. + opt_blobs = str(Path("/opt/blobs")) + spc_blobs = str(Path("/spc/blobs")) + assert f"-L{_shq(opt_blobs)}" in content + assert "-luser_blob" in content + assert f"-L{_shq(spc_blobs)}" in content + assert "-lspaced_blob" in content + for line in content.splitlines(): + if line.split(" = ")[0] in ("cflags", "cxxflags", "asflags"): + assert "user_blob" not in line + assert opt_blobs not in line + assert "spaced_blob" not in line + assert spc_blobs not in line + # System libraries with the selected lwIP variant, in the builder's order + assert ( + "-lhal -lphy -lpp -lnet80211 -llwip2-1460 -lwpa -lcrypto -lmain -lwps " + "-lbearssl -lespnow -lsmartconfig -lairkiss -lwpa2 -lspaced_blob " + "-luser_blob " + "-lstdc++ -lm -lc -lgcc" in content + ) + # Core exclusions: native OTA backend and waveform stubs + assert "Updater.cpp" not in content + assert "core_esp8266_waveform_pwm.cpp" not in content + assert "core_esp8266_waveform_phase.cpp" not in content + assert "core_esp8266_main.cpp.o" in content + # Assembly and C sources compile through their own rules + assert "cont.S.o: aspp" in content + assert "abi.c.o: c" in content + # throw_stubs is force-included for ESPHome sources only, via one shared + # srcflags variable rather than a copy of the flags line per edge + src_lines = [line for line in content.splitlines() if "obj/src/" in line] + assert any("main.cpp.o: cxx" in line for line in src_lines) + assert content.count("throw_stubs.h") == 1 + assert "srcflags = -include" in content + flags_lines = [ + line for line in content.splitlines() if line.startswith(" flags = ") + ] + assert flags_lines + assert all(line == " flags = $srcflags" for line in flags_lines) + + +def test_write_project_scanf_float_and_waveform_kept(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = True + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths) + assert "-u _scanf_float" in content + # Waveform not stubbed out: both implementations stay in the archive + assert "core_esp8266_waveform_pwm.cpp.o" in content + assert "core_esp8266_waveform_phase.cpp.o" in content + + @pytest.mark.parametrize( ("knob", "lib", "mss", "features", "ipv6"), [ @@ -360,8 +526,8 @@ def test_generate_ld_scripts(tmp_path: Path) -> None: _run_generate_ld_scripts(paths) mock_run.assert_not_called() - # A surgery edit invalidates the stamp via the fingerprint (a stale - # linker script would otherwise persist until an esphome clean) + # An edit to the surgery constants invalidates the stamp (a stale linker + # script would otherwise persist until an esphome clean) with ( patch.object( arduino8266.build_surgery, "surgery_fingerprint", return_value="changed" @@ -401,8 +567,6 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: paths = _make_framework(tmp_path) (paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( - # Real flash ld scripts carry no iram1_0_seg (that lives in the - # generated common ld); the surgery rejects one it was not asked about "MEMORY\n{\n" " dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n" " irom0_0_seg : org = 0x40201010, len = 0xfeff0\n" @@ -416,6 +580,96 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: assert "len = 0x2000000" in patched +def test_write_project_libraries_and_variant( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + + paths = _make_framework(tmp_path) + variant_src = paths.framework / "variants" / "nodemcu" / "variant.cpp" + variant_src.write_text("") + + lib_dir = tmp_path / "libsrc" + lib_dir.mkdir() + (lib_dir / "lib.cpp").write_text("") + (lib_dir / "impl.cc").write_text("") + headers_only = ArduinoLibrary(name="HeadersOnly", include_dirs=[lib_dir]) + library = ArduinoLibrary( + name="MyLib", + sources=[lib_dir / "impl.cc", lib_dir / "lib.cpp"], + include_dirs=[lib_dir], + flags=["-DMYLIB=1"], + link_dirs=[lib_dir / "blobs"], + link_libs=["algobsec"], + link_flags=["-Wl,--wrap=malloc"], + ) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS") + + with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.arduino8266"): + content = _write_ninja( + paths, libraries=[library, headers_only], ccache="/cc/ccache" + ) + + assert "build libFrameworkArduinoVariant.a: ar" in content + assert "build libMyLib.a: ar" in content + # A headers-only library contributes includes but no archive, with a + # debug log distinguishing it from a resolution failure + assert "libHeadersOnly.a" not in content + assert "Library HeadersOnly has no source files" in caplog.text + assert " flags = -DMYLIB=1" in content + assert "-lalgobsec" in content + # Library link flags reach the firmware link line; .cc compiles as C++ + assert "-Wl,--wrap=malloc" in content + assert "impl.cc.o: cxx" in content + assert f"-L{_shq(str(lib_dir / 'blobs'))}" in content + # Exceptions knob: -fexceptions and the exception-enabled stdc++ + assert "-fexceptions" in content + assert "-lstdc++-exc" in content + assert f"ccache = {_shq('/cc/ccache')}" in content + + +def test_get_flash_ld_path(tmp_path: Path) -> None: + + paths = InstalledPaths( + framework=tmp_path / "framework", + toolchain=tmp_path / "toolchain", + ninja=Path("ninja"), + ) + CORE.testing_mode = True + assert get_flash_ld_path(tmp_path, paths) == ( + tmp_path / "ld" / "testing_eagle.flash.4m.ld" + ) + + CORE.testing_mode = False + # Reads the same install the ninja file linked against; no re-resolve + assert get_flash_ld_path(tmp_path, paths) == ( + tmp_path / "framework" / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld" + ) + + +def test_flash_size_str() -> None: + assert _flash_size_str(4 * 1024 * 1024) == "4M" + assert _flash_size_str(512 * 1024) == "512K" + + +def test_write_project_testing_mode(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + CORE.testing_mode = True + _set_flags() + content = _write_ninja(paths) + assert "-T testing_eagle.flash.4m.ld" in content + assert "ld/testing_eagle.flash.4m.ld" in content + + +def test_write_project_missing_framework_dir_raises(tmp_path: Path) -> None: + """An incomplete framework install fails naming the missing path.""" + + paths = _make_framework(tmp_path) + shutil.rmtree(paths.framework / "tools" / "sdk" / "lwip2") + _set_flags() + with pytest.raises(EsphomeError, match="incomplete.*lwip2"): + _write_ninja(paths) + + def test_generate_ld_scripts_testing_mode_missing_flash_ld_raises( tmp_path: Path, ) -> None: @@ -440,6 +694,61 @@ def test_build_config_nonosdk_precedence() -> None: assert _resolve_build_config(_defines()).nonosdk == "NONOSDK221" +def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None: + """A lowercase .s source assembles plain (SCons AS), never through the + preprocessor rule that a .S source gets.""" + paths = _make_framework(tmp_path) + core_dir = paths.framework / "cores" / "esp8266" + (core_dir / "lowlevel.s").write_text("nop\n") + _set_flags() + content = _write_ninja(paths) + assert "lowlevel.s.o: asm " in content + assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in ( + content + ) + + +def test_write_project_unflags_operandless_linker_flag(tmp_path: Path) -> None: + """build_unflags: -nostdlib filters whole-token from both lines, as + PlatformIO allows; only operand-taking flags hard-error.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-nostdlib"} + content = _write_ninja(paths) + assert "-nostdlib" not in content + + +def test_write_project_unflagged_symbol_takes_its_dash_u(tmp_path: Path) -> None: + """Unflagging a -u symbol drops the -u that carried it; a dangling -u + would consume the next token and hand ld a symbol as an input file.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"_printf_float"} + content = _write_ninja(paths) + link_line = next( + line for line in content.splitlines() if line.startswith("linkflags = ") + ) + assert "_printf_float" not in link_line + assert "-u -u" not in link_line + # The neighbors survive as intact pairs + assert "-u app_entry" in link_line + assert "-u _DebugExceptionVector" in link_line + + +def test_write_project_build_unflags_apply_to_framework_flags(tmp_path: Path) -> None: + """build_unflags removes flags from the framework sets, as PlatformIO does.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-fipa-pta", "-Wl,--gc-sections"} + content = _write_ninja(paths) + for line in content.splitlines(): + key = line.split(" = ")[0] + if key in ("cflags", "cxxflags", "asflags"): + assert "-fipa-pta" not in line + if key == "linkflags": + assert "-Wl,--gc-sections" not in line + + def test_project_flags_trailing_bare_linker_flag_warns( caplog: pytest.LogCaptureFixture, ) -> None: @@ -494,10 +803,18 @@ def test_project_flags_requotes_lexed_defines() -> None: compile_flags, _link, _dirs, _libs = _split_flags() # shlex folds the quotes (as PIO's ParseFlags does); _shell_token # re-quotes the spaced token so the shell passes one argv element - expected = ( - '"-DGREETING=hello world"' if os.name == "nt" else "'-DGREETING=hello world'" - ) - assert compile_flags == [expected] + assert compile_flags == [_shq("-DGREETING=hello world")] + + +def test_write_project_empty_core_raises(tmp_path: Path) -> None: + """A framework tree with no core sources fails at generation, not link.""" + paths = _make_framework(tmp_path) + core = paths.framework / "cores" / "esp8266" + for f in core.iterdir(): + f.unlink() + _set_flags() + with pytest.raises(EsphomeError, match="no core sources"): + _write_ninja(paths) def test_flag_defines_joins_spaced_define() -> None: @@ -508,6 +825,75 @@ def test_flag_defines_joins_spaced_define() -> None: assert "" not in defines +def test_ninja_path_escaping() -> None: + """Build-statement paths and command-line paths escape differently.""" + assert arduino8266._e("a b:$c") == "a$ b$:$$c" + assert arduino8266._q("/a b/$x") == _shq("/a b/$$x") + + +def test_write_project_asm_excludes_non_define_user_flags(tmp_path: Path) -> None: + """The ASPPCOM command under PlatformIO never sees CCFLAGS, so only -D/-I user flags + reach assembly compiles.""" + paths = _make_framework(tmp_path) + _set_flags("-DUSER_KNOB=1", "-Wno-volatile") + content = _write_ninja(paths) + asflags = next(line for line in content.splitlines() if line.startswith("asflags")) + assert "-DUSER_KNOB=1" in asflags + assert "-Wno-volatile" not in asflags + cxxflags = next( + line for line in content.splitlines() if line.startswith("cxxflags") + ) + assert "-Wno-volatile" in cxxflags + + +def test_write_project_returns_changed(tmp_path: Path) -> None: + """The documented contract: True when build.ninja changed, False on an + identical regeneration (pins byte-stable output too).""" + paths = _make_framework(tmp_path) + _set_flags() + src = CORE.relative_src_path() + (src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + ): + assert arduino8266.write_project(paths, None) is True + assert arduino8266.write_project(paths, None) is False + + +def test_write_project_missing_elf2bin_raises(tmp_path: Path) -> None: + """A half-extracted package must fail by name at generation, not after + the full compile at the elf2bin edge.""" + paths = _make_framework(tmp_path) + (paths.framework / "tools" / "elf2bin.py").unlink() + _set_flags() + src = CORE.relative_src_path() + (src / "main.cpp").parent.mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + pytest.raises(EsphomeError, match="elf2bin"), + ): + arduino8266.write_project(paths, None) + + +def test_write_project_missing_src_dir_raises(tmp_path: Path) -> None: + """A missing generated source tree is its own error, not an install one.""" + paths = _make_framework(tmp_path) + _set_flags() + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + patch.object( + arduino8266.CORE, "relative_src_path", return_value=tmp_path / "nope" + ), + pytest.raises(EsphomeError, match="source directory"), + ): + arduino8266.write_project(paths, None) + + def test_build_config_custom_mmu_without_knob_raises() -> None: """Custom MMU sizes without the CUSTOM knob are refused.""" with pytest.raises(EsphomeError, match="PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"): @@ -600,6 +986,49 @@ def test_generate_ld_scripts_missing_compiler_is_clean(tmp_path: Path) -> None: _run_generate_ld_scripts(paths) +def test_write_project_asm_keeps_quoted_defines(tmp_path: Path) -> None: + """A spaced -D/-I user flag arrives shell-quoted; assembly must still + receive it.""" + paths = _make_framework(tmp_path) + _set_flags('-DGREETING="hello world"', "-Wno-volatile") + content = _write_ninja(paths) + asflags = next(line for line in content.splitlines() if line.startswith("asflags")) + assert _shq("-DGREETING=hello world") in asflags + assert "-Wno-volatile" not in asflags + + +def test_write_project_unarchived_library_links_objects(tmp_path: Path) -> None: + """A libArchive:false library's objects reach the link directly.""" + + paths = _make_framework(tmp_path) + lib_src = tmp_path / "gdb" / "src" + lib_src.mkdir(parents=True) + (lib_src / "GDBStub.cpp").write_text("") + _set_flags() + lib = ArduinoLibrary( + name="GDBStub", + sources=[lib_src / "GDBStub.cpp"], + include_dirs=[lib_src], + lib_archive=False, + ) + content = _write_ninja(paths, libraries=[lib]) + assert "libGDBStub.a" not in content + link_line = next( + line for line in content.splitlines() if line.startswith("build firmware.elf") + ) + assert "GDBStub.cpp.o" in link_line + + +def test_write_project_unknown_board_fails_by_name(tmp_path: Path) -> None: + """A caller bypassing config validation gets the board named, not a + KeyError.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.data[KEY_ESP8266][KEY_BOARD] = "not_a_board" + with pytest.raises(EsphomeError, match="'not_a_board' is not supported"): + _write_ninja(paths) + + def test_unflag_tokens_join_spaced_entries() -> None: """Spaced build_unflags entries ("-D FOO") match the joined token.""" CORE.build_unflags = {"-D FOO", "-l bar"} @@ -1072,6 +1501,31 @@ def test_generate_ld_scripts_surgery_failure_is_named(tmp_path: Path) -> None: _run_generate_ld_scripts(paths) +def test_write_project_unmatched_unflag_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unflag that removes nothing is named; a matching one is silent.""" + paths = _make_framework(tmp_path) + _set_flags("-DUSE_FOO=1") + CORE.build_unflags = {"-DUSE_FOO", "-Os"} + content = _write_ninja(paths) + assert "matched no build flag: -DUSE_FOO" in caplog.text + assert "-Os" not in caplog.text.split("matched no build flag")[-1].splitlines()[0] + # The matching -Os unflag really removed the framework flag + cflags = next(line for line in content.splitlines() if line.startswith("cflags")) + assert " -Os " not in cflags + + +def test_write_project_lexes_build_flags_once( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A malformed build_flags entry warns once per generation.""" + paths = _make_framework(tmp_path) + _set_flags("-DFOO=1 -l") + _write_ninja(paths) + assert caplog.text.count("Ignoring trailing '-l'") == 1 + + def test_build_config_mmu_conflict_names_the_variant_knob_with_custom() -> None: """With MMU_CUSTOM also set, the actionable fix is dropping the variant knob, not setting the knob the user already set.""" @@ -1171,6 +1625,11 @@ def test_generate_ld_scripts_unreadable_header_forces_regeneration( mock_run.assert_called_once() +def test_board_tables_are_equal() -> None: + """BOARDS and ESP8266_BOARD_BUILD must stay exactly in sync.""" + assert set(BOARDS) == set(ESP8266_BOARD_BUILD) + + def test_bare_include_and_define_dropped( caplog: pytest.LogCaptureFixture, ) -> None: @@ -1208,3 +1667,49 @@ def test_defines_flags_honors_f_cpu_override() -> None: CORE.platformio_options = {"board_build.f_cpu": "160000000L"} defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"]) assert "-DF_CPU=160000000L" in defines + + +def test_flash_ld_name_honors_ldscript_override(tmp_path: Path) -> None: + """board_build.ldscript (filesystem reservation, corrected flash size) + replaces the board default; a path is rejected since the name resolves + via the -L search path.""" + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld" + CORE.platformio_options = {"board_build.ldscript": "eagle.flash.4m2m.ld"} + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m2m.ld" + paths = _make_framework(tmp_path) + _set_flags() + content = _write_ninja(paths) + assert "-T eagle.flash.4m2m.ld" in content + CORE.platformio_options = {"board_build.ldscript": "../evil.ld"} + with pytest.raises(EsphomeError, match="bare script name"): + arduino8266._flash_ld_name("nodemcuv2") + + +def test_unflagging_a_plain_linker_flag_raises(tmp_path: Path) -> None: + """build_unflags: -u would strip all seven -u tokens and leave the + operands as ld input files; refuse by name instead.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-u _printf_float"} + with pytest.raises(EsphomeError, match="cannot remove plain linker"): + _write_ninja(paths) + + +def test_d1_wroom_02_keeps_its_shipped_flash_layout() -> None: + """The board joined BOARDS late; the flash-size default (2m.ld) would + move _FS_end and the preferences sector on existing devices.""" + assert arduino8266._flash_ld_name("d1_wroom_02") == "eagle.flash.2m64.ld" + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld" + + +def test_write_project_rejects_spaced_ldscript_override(tmp_path: Path) -> None: + """A spaced override never reaches the link line: generate_ld_scripts + rejects the name first (the -T _shell_token quoting behind it is + defence-in-depth).""" + CORE.platformio_options = {"board_build.ldscript": "my script.ld"} + paths = _make_framework(tmp_path) + _set_flags() + # The completeness checks run first; give them a src dir to pass + CORE.relative_src_path().mkdir(parents=True, exist_ok=True) + with pytest.raises(EsphomeError, match="Invalid flash linker script name"): + arduino8266.write_project(paths, None)