From 61e32230d9210136c2f6bd8e820c5b148fcec542 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 13:02:52 -0500 Subject: [PATCH] Add the ESP8266 native build spec (knobs, defines, flags, linker scripts) --- esphome/build_gen/arduino8266.py | 428 ++++++++++++++++ .../unit_tests/build_gen/test_arduino8266.py | 460 ++++++++++++++++++ 2 files changed, 888 insertions(+) create mode 100644 esphome/build_gen/arduino8266.py create mode 100644 tests/unit_tests/build_gen/test_arduino8266.py diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py new file mode 100644 index 0000000000..fb46cfd7f1 --- /dev/null +++ b/esphome/build_gen/arduino8266.py @@ -0,0 +1,428 @@ +"""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``) into a ``build.ninja`` under +``.pioenvs//``. The flag sets, defines, link line, linker-script +generation, and ``elf2bin`` invocation deliberately match what PlatformIO +produces so the binaries stay near-identical between the two toolchains. + +The ``PIO_FRAMEWORK_ARDUINO_*`` knob defines (lwIP variant, NONOS SDK +version, MMU layout, exceptions, waveform phase) keep working: they are read +from the build flags with the same precedence as the PlatformIO builder. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import logging +from pathlib import Path +import re +import subprocess + +from esphome.components.esp8266 import build_surgery +from esphome.components.esp8266.boards import BOARDS, ESP8266_LD_SCRIPTS +from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_FLASH_SIZE +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError +from esphome.helpers import mkdir_p, write_file_if_changed +from esphome.platformio.library import join_flag_args, split_flag_entry + +_LOGGER = logging.getLogger(__name__) + +# From platformio-build.py. The first entry is the default; with multiple SDK +# knobs set (a pathological config) ties break by table order, since +# upstream's tie-break depends on define order and is not reproducible here. +_NONOSDK_VERSIONS = ( + ("SDK22x_190703", "NONOSDK22x_190703"), + ("SDK221", "NONOSDK221"), + ("SDK22x_190313", "NONOSDK22x_190313"), + ("SDK22x_191024", "NONOSDK22x_191024"), + ("SDK22x_191105", "NONOSDK22x_191105"), + ("SDK22x_191122", "NONOSDK22x_191122"), + ("SDK305", "NONOSDK305"), +) + +# knob define -> (TCP_MSS, LWIP_FEATURES, LWIP_IPV6, library name) +_LWIP_VARIANTS = ( + ("PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", (536, 1, 1, "lwip6-536-feat")), + ( + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH", + (1460, 1, 1, "lwip6-1460-feat"), + ), + ("PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH", (1460, 1, 0, "lwip2-1460-feat")), + ("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH", (536, 0, 0, "lwip2-536")), + ( + "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + (1460, 0, 0, "lwip2-1460"), + ), +) +_LWIP_DEFAULT = (536, 1, 0, "lwip2-536-feat") + +_ASFLAGS = ["-mlongcalls", "-mtext-section-literals"] +_CFLAGS = [ + "-std=gnu17", + "-Wpointer-arith", + "-Wno-implicit-function-declaration", + "-Wl,-EL", + "-fno-inline-functions", + "-nostdlib", +] +_CCFLAGS = [ + "-Os", + "-mlongcalls", + "-mtext-section-literals", + "-falign-functions=4", + "-U__STRICT_ANSI__", + "-ffunction-sections", + "-fdata-sections", + "-Wall", + "-Werror=return-type", + "-free", + "-fipa-pta", +] +_LINKFLAGS = [ + "-Os", + "-nostdlib", + "-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", +] +_SYSTEM_LIBS_PRE_LWIP = ["hal", "phy", "pp", "net80211"] +_SYSTEM_LIBS_POST_LWIP = [ + "wpa", + "crypto", + "main", + "wps", + "bearssl", + "espnow", + "smartconfig", + "airkiss", + "wpa2", +] + + +@dataclass +class _BuildConfig: + """Knob-derived build configuration (PIO_FRAMEWORK_ARDUINO_* defines).""" + + nonosdk: str + lwip_lib: str + exceptions: bool + vtables: str + fp_in_irom: bool + knob_defines: list[str] = field(default_factory=list) + mmu_defines: list[str] = field(default_factory=list) + + +def _flag_defines() -> dict[str, str]: + """Map define name -> full ``NAME[=VALUE]`` for every -D build flag.""" + defines: dict[str, str] = {} + for flag in CORE.build_flags: + # Shell-lex multi-token entries the way PlatformIO does, so a knob + # in "-DKNOB -DOTHER" or a spaced "-D KNOB" is still detected; + # single tokens pass verbatim to keep quoting in their bodies intact. + tokens = ( + join_flag_args(split_flag_entry(flag, "esphome"), "esphome") + if " " in flag + else (flag,) + ) + for tok in tokens: + if tok.startswith("-D") and len(tok) > 2: + body = tok[2:] + defines[body.split("=", 1)[0]] = body + return defines + + +def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: + nonosdk = _NONOSDK_VERSIONS[0][1] + for name, define in _NONOSDK_VERSIONS: + if f"PIO_FRAMEWORK_ARDUINO_ESPRESSIF_{name}" in defines: + nonosdk = define + break + + tcp_mss, features, ipv6, lwip_lib = _LWIP_DEFAULT + for knob, variant in _LWIP_VARIANTS: + if knob in defines: + tcp_mss, features, ipv6, lwip_lib = variant + break + + knob_defines = [ + f"{nonosdk}=1", + f"TCP_MSS={tcp_mss}", + f"LWIP_FEATURES={features}", + f"LWIP_IPV6={ipv6}", + ] + if "PIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE" in defines: + knob_defines.append("WAVEFORM_LOCKED_PHASE=1") + + # Sorted so the pick is deterministic: the dict is built from a set of + # build flags, whose iteration order varies between processes. + vtables = next( + (name for name in sorted(defines) if name.startswith("VTABLES_IN_")), + "VTABLES_IN_FLASH", + ) + + if "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48" in defines: + mmu = ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000"] + elif "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED" in defines: + mmu = ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000", "MMU_IRAM_HEAP"] + elif "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED" in defines: + mmu = [ + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_SEC_HEAP_SIZE=0x4000", + "MMU_SEC_HEAP=0x40108000", + ] + elif "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K" in defines: + mmu = [ + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x8000", + "MMU_EXTERNAL_HEAP=128", + ] + elif "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K" in defines: + mmu = [ + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x8000", + "MMU_EXTERNAL_HEAP=256", + ] + elif "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines: + if "MMU_IRAM_SIZE" not in defines or "MMU_ICACHE_SIZE" not in defines: + raise EsphomeError( + "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM requires MMU_IRAM_SIZE and " + "MMU_ICACHE_SIZE build flags" + ) + # Sorted so build.ninja and the linker-script stamp stay byte-stable + # across runs (the flag set has no deterministic iteration order). + mmu = sorted(body for name, body in defines.items() if name.startswith("MMU_")) + else: + mmu = ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000"] + + return _BuildConfig( + nonosdk=nonosdk, + lwip_lib=lwip_lib, + exceptions="PIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS" in defines, + vtables=vtables, + fp_in_irom="FP_IN_IROM" in defines, + knob_defines=knob_defines, + mmu_defines=mmu, + ) + + +def _flash_ld_name(board: str) -> str: + return ESP8266_LD_SCRIPTS[BOARDS[board][KEY_FLASH_SIZE]][1] + + +def _e(value) -> str: + """Escape a path or token for a ninja file.""" + return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ") + + +def _quote_arg(tok: str) -> str: + """Wrap a token in double quotes with the Windows argv rule. + + Same escaping rule as ``subprocess.list2cmdline``: a backslash run + doubles only immediately before a quote (or the closing quote), and the + quote itself is escaped. POSIX sh parses the result identically for + backslashes and quotes. ``$`` must already be doubled for ninja. + """ + quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok) + quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted) + return f'"{quoted}"' + + +def _q(value) -> str: + """Force-quote a path for the ninja command line (shell/CreateProcess).""" + return _quote_arg(str(value).replace("$", "$$")) + + +_NEEDS_QUOTE = re.compile(r'[\s"\']') + + +def _shell_token(tok: str) -> str: + """Quote a lexed token only when needed; ``_q`` force-quotes paths. + + Lexing strips the quoting a user wrote (``-DX="a b"`` becomes the single + token ``-DX=a b``); re-quote on the way out so the compiler receives the + same argv element SCons would pass under PlatformIO. After ninja + un-doubles ``$$``, sh still expands ``$VAR`` while CreateProcess passes + it literally -- the same divergence SCons-under-sh has, so this stays + PlatformIO parity. + """ + tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing + if not _NEEDS_QUOTE.search(tok): + return tok + return _quote_arg(tok) + + +def _defines_flags( + config: _BuildConfig, flash_mode: str, board: str, board_defines: tuple[str, ...] +) -> list[str]: + return [ + f"-D{d}" + for d in ( + "F_CPU=80000000L", + "__ets__", + "ICACHE_FLASH", + "_GNU_SOURCE", + "ARDUINO=10805", + f'ARDUINO_BOARD=\\"PLATFORMIO_{board.upper()}\\"', + f'ARDUINO_BOARD_ID=\\"{board}\\"', + f"FLASHMODE_{flash_mode.upper()}", + "LWIP_OPEN_SRC", + *config.knob_defines, + config.vtables, + *config.mmu_defines, + "ESP8266", + "ARDUINO_ARCH_ESP8266", + *board_defines, + ) + ] + + +def _unflag_tokens() -> set[str]: + """``build_unflags`` entries shell-lexed to tokens, as PlatformIO matches.""" + return { + tok + for entry in CORE.build_unflags + for tok in split_flag_entry(entry, "esphome build_unflags") + } + + +def _project_flags( + unflags: set[str] | None = None, +) -> tuple[list[str], list[str], list[Path], list[str]]: + """Split the ESPHome build flags into compile, linker, -L, and -l lists. + + Every entry is shell-lexed the way PlatformIO's ``ParseFlags`` does, so a + linker flag anywhere in an entry reaches the link line and + ``build_unflags`` matches individual tokens (``-Os`` inside ``-Os -g3``). + Lexed tokens are re-quoted at emission via ``_shell_token``. + """ + if unflags is None: + unflags = _unflag_tokens() + compile_flags: list[str] = [] + link_flags: list[str] = [] + lib_dirs: list[Path] = [] + libs: list[str] = [] + for flag in sorted(CORE.build_flags): + for tok in join_flag_args(split_flag_entry(flag, "esphome"), "esphome"): + if tok in unflags: + continue + if tok.startswith("-Wl,"): + link_flags.append(_shell_token(tok)) + elif tok.startswith("-L"): + lib_dirs.append(Path(tok[2:])) + elif tok.startswith("-l"): + libs.append(tok[2:]) + else: + compile_flags.append(_shell_token(tok)) + return compile_flags, link_flags, lib_dirs, libs + + +def generate_ld_scripts( + paths: dict[str, Path], config: _BuildConfig, flash_ld_name: str +) -> None: + """Generate the common linker script (and testing-mode flash ld copy). + + Runs the same preprocessor invocation as the PlatformIO builder over + ``eagle.app.v6.common.ld.h``, then applies ESPHome's surgeries: the wifi + rate-table DRAM relocation, and enlarged memory segments in testing mode. + """ + framework = paths["framework_path"] + gcc = paths["toolchain_path"] / "bin" / "xtensa-lx106-elf-gcc" + ld_dir = CORE.relative_pioenvs_path(CORE.name, "ld") + mkdir_p(ld_dir) + + cmd = [str(gcc), "-CC", "-E", "-P", f"-D{config.vtables}"] + cmd += [f"-D{d}" for d in config.mmu_defines] + if config.fp_in_irom: + cmd.append("-DFP_IN_IROM") + cmd += [ + str(framework / "tools" / "sdk" / "ld" / "eagle.app.v6.common.ld.h"), + "-o", + "-", + ] + + # The inputs are the command line (defines + framework version, which is + # baked into the paths) plus testing mode; skip the preprocessor spawn on + # incremental builds when nothing changed. + output = ld_dir / "local.eagle.app.v6.common.ld" + stamp = ld_dir / ".local.eagle.app.v6.common.ld.stamp" + # The surgery constants are inputs too: an edit to build_surgery.py must + # invalidate existing build dirs, not wait for an esphome clean. + stamp_content = ( + " ".join(cmd) + + f" testing={CORE.testing_mode}" + + f" {build_surgery.RATETABLE_RULE}" + + f" {build_surgery.TESTING_IRAM_SIZE}" + + f" {build_surgery.TESTING_DRAM_SIZE}" + + f" {build_surgery.TESTING_FLASH_SIZE}" + ) + if not ( + output.is_file() + and stamp.is_file() + and stamp.read_text(encoding="utf-8") == stamp_content + ): + result = subprocess.run( + cmd, capture_output=True, text=True, check=False, close_fds=False + ) + if result.returncode != 0: + raise EsphomeError(f"Generating the linker script failed:\n{result.stderr}") + content = build_surgery.relocate_ratetable(result.stdout) + if CORE.testing_mode: + content = build_surgery.apply_testing_memory_patches( + content, require=("iram1_0_seg",) + ) + write_file_if_changed(output, content) + stamp.write_text(stamp_content, encoding="utf-8") + + if CORE.testing_mode: + # A patched copy of the flash ld in the build dir; resolved through + # the same -L path as the SDK original it shadows. + flash_ld = framework / "tools" / "sdk" / "ld" / flash_ld_name + write_file_if_changed( + ld_dir / f"testing_{flash_ld_name}", + build_surgery.apply_testing_memory_patches( + flash_ld.read_text(encoding="utf-8"), + require=("dram0_0_seg", "irom0_0_seg"), + ), + ) + + +def get_flash_ld_path(build_dir: Path) -> Path: + """The flash linker script the link actually uses (for size reporting).""" + from esphome.arduino8266.framework import ( + framework_package_version, + get_framework_path, + ) + + name = _flash_ld_name(CORE.data[KEY_ESP8266][KEY_BOARD]) + if CORE.testing_mode: + return build_dir / "ld" / f"testing_{name}" + version = framework_package_version(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) + return get_framework_path(version) / "tools" / "sdk" / "ld" / name + + +def _flash_size_str(flash_ld_name: str) -> str: + """Flash size for elf2bin, derived from the ld script name (PIO logic).""" + match = re.search(r"\.flash\.(\d+)([mk])", flash_ld_name) + if not match: + raise EsphomeError(f"Cannot parse flash size from {flash_ld_name}") + return f"{match.group(1)}{match.group(2).upper()}" diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py new file mode 100644 index 0000000000..b214e45e49 --- /dev/null +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -0,0 +1,460 @@ +"""Drift tests for the native ESP8266 Arduino build generator. + +These pin the build spec transliterated from the PlatformIO builder +(framework-arduinoespressif8266/tools/platformio-build.py and +platform-espressif8266/builder/main.py) so a change on either side of the +toolchain seam is caught: the knob-define precedence, the define/flag sets, +the link line, and the core source exclusions must keep matching what the +PlatformIO toolchain produces for the same configuration. +""" + +from __future__ import annotations + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +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 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, +) +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def _setup_core(tmp_path: Path) -> Generator[None]: + CORE.name = "test8266" + CORE.build_path = tmp_path + CORE.testing_mode = False + CORE.cpp_standard = "gnu++20" + 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, + } + yield + # CORE.reset() (the suite-wide autouse fixture) does not clear this flag + CORE.testing_mode = False + + +def _set_flags(*flags: str) -> None: + CORE.build_flags = set(flags) + + +def test_board_build_covers_every_board() -> None: + """Every supported board must have variant/define metadata.""" + assert set(BOARDS) <= set(ESP8266_BOARD_BUILD) + + +def test_build_config_defaults() -> None: + + _set_flags() + config = _resolve_build_config(_flag_defines()) + assert config.nonosdk == "NONOSDK22x_190703" + assert config.lwip_lib == "lwip2-536-feat" + assert not config.exceptions + assert config.vtables == "VTABLES_IN_FLASH" + assert config.knob_defines == [ + "NONOSDK22x_190703=1", + "TCP_MSS=536", + "LWIP_FEATURES=1", + "LWIP_IPV6=0", + ] + assert config.mmu_defines == ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000"] + + +def test_build_config_esphome_lwip_knob() -> None: + """The lwIP variant ESPHome selects maps to the same defines and library + as the PlatformIO builder.""" + + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + config = _resolve_build_config(_flag_defines()) + assert config.lwip_lib == "lwip2-1460" + assert "TCP_MSS=1460" in config.knob_defines + assert "LWIP_FEATURES=0" in config.knob_defines + assert "LWIP_IPV6=0" in config.knob_defines + + +def test_build_config_knobs() -> None: + + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305", + "-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS", + "-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", + "-DVTABLES_IN_DRAM", + ) + config = _resolve_build_config(_flag_defines()) + assert config.nonosdk == "NONOSDK305" + assert config.exceptions + assert config.vtables == "VTABLES_IN_DRAM" + assert config.mmu_defines == ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000"] + + +def test_build_config_mmu_custom_requires_sizes() -> None: + + _set_flags("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM") + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE"): + _resolve_build_config(_flag_defines()) + + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0xC000", + "-DMMU_ICACHE_SIZE=0x4000", + ) + config = _resolve_build_config(_flag_defines()) + # Emitted pre-sorted so build.ninja stays byte-stable across runs + assert config.mmu_defines == [ + "MMU_ICACHE_SIZE=0x4000", + "MMU_IRAM_SIZE=0xC000", + ] + + +def test_defines_match_platformio_builder() -> None: + """The exact define set the PlatformIO builder passes for nodemcuv2/dout.""" + + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + assert _defines_flags( + _resolve_build_config(_flag_defines()), + "dout", + "nodemcuv2", + ESP8266_BOARD_BUILD["nodemcuv2"]["defines"], + ) == [ + "-DF_CPU=80000000L", + "-D__ets__", + "-DICACHE_FLASH", + "-D_GNU_SOURCE", + "-DARDUINO=10805", + '-DARDUINO_BOARD=\\"PLATFORMIO_NODEMCUV2\\"', + '-DARDUINO_BOARD_ID=\\"nodemcuv2\\"', + "-DFLASHMODE_DOUT", + "-DLWIP_OPEN_SRC", + "-DNONOSDK22x_190703=1", + "-DTCP_MSS=1460", + "-DLWIP_FEATURES=0", + "-DLWIP_IPV6=0", + "-DVTABLES_IN_FLASH", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DESP8266", + "-DARDUINO_ARCH_ESP8266", + "-DARDUINO_ESP8266_NODEMCU_ESP12E", + ] + + +def _make_framework(tmp_path: Path) -> dict[str, Path]: + framework = tmp_path / "framework" + core = framework / "cores" / "esp8266" + core.mkdir(parents=True) + for name in ( + "core_esp8266_main.cpp", + "Updater.cpp", + "core_esp8266_waveform_pwm.cpp", + "core_esp8266_waveform_phase.cpp", + "cont.S", + "abi.c", + ): + (core / name).write_text("") + (framework / "variants" / "nodemcu").mkdir(parents=True) + for sub in ("include", "ld", "lwip2/include", "lib"): + (framework / "tools" / "sdk" / sub).mkdir(parents=True) + (framework / "libraries").mkdir() + toolchain = tmp_path / "toolchain" + (toolchain / "bin").mkdir(parents=True) + (toolchain / "include").mkdir() + return { + "framework_path": framework, + "toolchain_path": toolchain, + "ninja_path": Path("ninja"), + } + + +@pytest.mark.parametrize( + ("knob", "lib", "mss", "features", "ipv6"), + [ + ("PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", "lwip6-536-feat", 536, 1, 1), + ( + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH", + "lwip6-1460-feat", + 1460, + 1, + 1, + ), + ("PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH", "lwip2-1460-feat", 1460, 1, 0), + ("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH", "lwip2-536", 536, 0, 0), + ], +) +def test_build_config_lwip_variants( + knob: str, lib: str, mss: int, features: int, ipv6: int +) -> None: + """Every lwIP knob maps to the same defines and library as the PIO builder.""" + + _set_flags(f"-D{knob}") + config = _resolve_build_config(_flag_defines()) + assert config.lwip_lib == lib + assert f"TCP_MSS={mss}" in config.knob_defines + assert f"LWIP_FEATURES={features}" in config.knob_defines + assert f"LWIP_IPV6={ipv6}" in config.knob_defines + + +@pytest.mark.parametrize( + ("knob", "expected"), + [ + ( + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED", + ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000", "MMU_IRAM_HEAP"], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED", + [ + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_SEC_HEAP_SIZE=0x4000", + "MMU_SEC_HEAP=0x40108000", + ], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K", + ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=128"], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K", + ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=256"], + ), + ], +) +def test_build_config_mmu_variants(knob: str, expected: list[str]) -> None: + + _set_flags(f"-D{knob}") + assert _resolve_build_config(_flag_defines()).mmu_defines == expected + + +def test_build_config_waveform_locked_phase() -> None: + + _set_flags("-DPIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE", "-DFP_IN_IROM") + config = _resolve_build_config(_flag_defines()) + assert "WAVEFORM_LOCKED_PHASE=1" in config.knob_defines + assert config.fp_in_irom + + +_COMMON_LD_H_OUTPUT = """\ +MEMORY +{ + iram1_0_seg : org = 0x40100000, len = 0x8000 +} +SECTIONS +{ + .data : ALIGN(4) + { + _data_start = ABSOLUTE(.); + } >dram0_0_seg :dram0_0_phdr +} +""" + + +def _run_generate_ld_scripts(paths: dict[str, Path]) -> Path: + + config = _resolve_build_config(_flag_defines()) + arduino8266.generate_ld_scripts(paths, config, "eagle.flash.4m.ld") + return CORE.relative_pioenvs_path(CORE.name, "ld") + + +def test_generate_ld_scripts(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + _set_flags("-DFP_IN_IROM") + result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT) + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + ld_dir = _run_generate_ld_scripts(paths) + content = (ld_dir / "local.eagle.app.v6.common.ld").read_text() + assert RATETABLE_RULE in content + cmd = mock_run.call_args[0][0] + assert "-DVTABLES_IN_FLASH" in cmd + assert "-DMMU_IRAM_SIZE=0x8000" in cmd + assert "-DFP_IN_IROM" in cmd + + # Unchanged inputs skip the preprocessor spawn on the next run + with patch.object(arduino8266.subprocess, "run") as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_not_called() + + # 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, "TESTING_FLASH_SIZE", "0x3000000"), + patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run, + ): + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_generate_ld_scripts_failure(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + result = MagicMock(returncode=1, stderr="nope") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="linker script failed"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + (paths["framework_path"] / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( + "MEMORY\n{\n" + " dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n" + " iram1_0_seg : org = 0x40100000, len = 0x8000\n" + " irom0_0_seg : org = 0x40201010, len = 0xfeff0\n" + "}\n" + ) + CORE.testing_mode = True + result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT) + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + patched = (ld_dir / "testing_eagle.flash.4m.ld").read_text() + assert "len = 0x2000000" in patched + + +def test_get_flash_ld_path(tmp_path: Path) -> None: + + CORE.testing_mode = True + assert get_flash_ld_path(tmp_path) == ( + tmp_path / "ld" / "testing_eagle.flash.4m.ld" + ) + + CORE.testing_mode = False + with ( + patch( + "esphome.arduino8266.framework.get_framework_path", + return_value=tmp_path / "framework", + ), + patch( + "esphome.arduino8266.framework.framework_package_version", + return_value="3.30102.0", + ), + ): + assert get_flash_ld_path(tmp_path) == ( + tmp_path / "framework" / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld" + ) + + +def test_flash_size_str() -> None: + + assert _flash_size_str("eagle.flash.4m.ld") == "4M" + assert _flash_size_str("eagle.flash.512k.ld") == "512K" + with pytest.raises(EsphomeError, match="Cannot parse flash size"): + _flash_size_str("bogus.ld") + + +def test_build_config_nonosdk_precedence() -> None: + """With two SDK knobs set (a pathological config), ties break + deterministically by table order.""" + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305", + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK221", + ) + assert _resolve_build_config(_flag_defines()).nonosdk == "NONOSDK221" + + +def test_project_flags_trailing_bare_linker_flag_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + _set_flags("-l") + compile_flags, link_flags, lib_dirs, libs = arduino8266._project_flags() + assert "Ignoring trailing '-l'" in caplog.text + assert not libs + assert not lib_dirs + assert "-l" not in compile_flags + assert "-l" not in link_flags + + +def test_project_flags_lexed_entry_scatters_non_linker_tokens() -> None: + _set_flags("-L /d -Wl,-Map=m stray") + compile_flags, link_flags, lib_dirs, libs = arduino8266._project_flags() + assert lib_dirs == [Path("/d")] + assert link_flags == ["-Wl,-Map=m"] + assert "stray" in compile_flags + assert not libs + + +def test_flag_defines_lexes_multi_token_entries() -> None: + """A knob inside a multi-token entry is detected like PlatformIO does.""" + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH -DFOO=1 -Os") + defines = _flag_defines() + assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines + assert defines["FOO"] == "FOO=1" + config = _resolve_build_config(defines) + assert config.lwip_lib == "lwip2-1460" + + +def test_project_flags_lexes_every_entry() -> None: + """A linker flag anywhere in an entry reaches the link line (PIO parity).""" + _set_flags("-DFOO=1 -lbar") + compile_flags, _link, _dirs, libs = arduino8266._project_flags() + assert libs == ["bar"] + assert "-DFOO=1" in compile_flags + + +def test_project_flags_unflags_match_tokens() -> None: + """build_unflags removes a token embedded in a multi-token entry.""" + _set_flags("-Os -g3") + CORE.build_unflags = {"-Os"} + compile_flags, _link, _dirs, _libs = arduino8266._project_flags( + arduino8266._unflag_tokens() + ) + assert "-g3" in compile_flags + assert "-Os" not in compile_flags + + +def test_project_flags_requotes_lexed_defines() -> None: + """A quoted spaced value stays one compiler argument after lex/emit.""" + _set_flags('-DGREETING="hello world"') + compile_flags, _link, _dirs, _libs = arduino8266._project_flags() + # shlex folds the quotes (as PIO's ParseFlags does); _shell_token + # re-quotes the spaced token so the shell passes one argv element + assert compile_flags == ['"-DGREETING=hello world"'] + + +def test_shell_token_escaping() -> None: + """Tokens survive both POSIX sh and the Windows CRT argv parser.""" + assert arduino8266._shell_token("-Os") == "-Os" + # $ would be expanded (to nothing) by ninja itself + assert arduino8266._shell_token("-DX=$HOME") == "-DX=$$HOME" + # Backslashes not before a quote stay single (Windows path in a define) + assert arduino8266._shell_token("-DP=C:\\x y") == '"-DP=C:\\x y"' + # A quote is escaped and the preceding backslash run doubles + assert arduino8266._shell_token('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"' + # A trailing backslash run doubles before the closing quote + assert arduino8266._shell_token("a b\\") == '"a b\\\\"' + + +def test_flag_defines_joins_spaced_define() -> None: + """A spaced "-D KNOB" entry is detected exactly as PlatformIO detects it.""" + _set_flags("-D PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + defines = _flag_defines() + assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines + 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") == '"/a b/$$x"'