[esp8266] Add the native ninja emission (#18560)

This commit is contained in:
J. Nick Koston
2026-09-24 10:45:54 +12:00
committed by GitHub
parent e3951f2ab4
commit f4300d9cee
4 changed files with 1032 additions and 33 deletions
+495 -7
View File
@@ -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/<name>/``. 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"
+5 -1
View File
@@ -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",
+3 -1
View File
@@ -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")
+529 -24
View File
@@ -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 <src_dir>."""
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)