mirror of
https://github.com/esphome/esphome.git
synced 2026-09-28 07:20:24 +00:00
[esp8266] Add the native ninja emission (#18560)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user